diff --git a/packages/sync-actions/CHANGELOG.md b/packages/sync-actions/CHANGELOG.md new file mode 100644 index 000000000..665d3d75e --- /dev/null +++ b/packages/sync-actions/CHANGELOG.md @@ -0,0 +1,155 @@ +# @commercetools/sync-actions + +## 5.15.0 + +### Minor Changes + +- [#1885](https://github.com/commercetools/nodejs/pull/1885) [`d6cb2740`](https://github.com/commercetools/nodejs/commit/d6cb27401279cb42a49366f32802f8ca8c7f01a3) Thanks [@kafis](https://github.com/kafis)! - Add support for 'changeAssetOrder' in (ProductVariants)[https://docs.commercetools.com/api/projects/products#change-asset-order]. + +## 5.14.0 + +### Minor Changes + +- [#1876](https://github.com/commercetools/nodejs/pull/1876) [`27f0d2b6`](https://github.com/commercetools/nodejs/commit/27f0d2b66fefbe082b6a27e7fa940b09e7e6088c) Thanks [@jaikumar-tj](https://github.com/jaikumar-tj)! - Add support for attribute groups `changeName`, `setKey`, `setDescription`, `addAttribute` and `removeAttribute` actions. + +## 5.13.0 + +### Minor Changes + +- [#1874](https://github.com/commercetools/nodejs/pull/1874) [`69f4501d`](https://github.com/commercetools/nodejs/commit/69f4501dc5401ab2b44f4d3096a978094e402c9f) Thanks [@taylor-knapp](https://github.com/taylor-knapp)! - Handle long text values performantly + +## 5.12.2 + +### Patch Changes + +- [#1871](https://github.com/commercetools/nodejs/pull/1871) [`4f8ea39b`](https://github.com/commercetools/nodejs/commit/4f8ea39b66ddd5014ac8f923ed980584bd96290c) Thanks [@ARRIOLALEO](https://github.com/ARRIOLALEO)! - rollback setPriceTiers name change + +## 5.12.1 + +### Patch Changes + +- [#1869](https://github.com/commercetools/nodejs/pull/1869) [`7285a9fb`](https://github.com/commercetools/nodejs/commit/7285a9fbcbcfca6a9460e36ba7b58bb30f34fac6) Thanks [@ARRIOLALEO](https://github.com/ARRIOLALEO)! - Add support for StandalonePrice `setPriceTier` + +## 5.12.0 + +### Minor Changes + +- [#1863](https://github.com/commercetools/nodejs/pull/1863) [`7ed7a663`](https://github.com/commercetools/nodejs/commit/7ed7a663c1cb3aa87bfb4b4c2c008949a66a62e0) Thanks [@ragafus](https://github.com/ragafus)! - Add support for StandalonePrice `setKey`, `setValidFrom`, `setValidUntil`, `setValidFromAndUntil` and `changeActive` actions. + +## 5.11.0 + +### Minor Changes + +- [#1864](https://github.com/commercetools/nodejs/pull/1864) [`91f6b617`](https://github.com/commercetools/nodejs/commit/91f6b61794e7d66766097965e452e14c85e40f14) Thanks [@ARRIOLALEO](https://github.com/ARRIOLALEO)! - Add support for StandalonePrice `setPriceTiers` + +## 5.10.0 + +### Minor Changes + +- [#1856](https://github.com/commercetools/nodejs/pull/1856) [`9a3e3711`](https://github.com/commercetools/nodejs/commit/9a3e3711bf6594deafb5d54a9ce9e32450f9c4d6) Thanks [@qmateub](https://github.com/qmateub)! - orders sync-actions: support action on delivery items `setDeliveryItems` + +## 5.9.0 + +### Minor Changes + +- [#1853](https://github.com/commercetools/nodejs/pull/1853) [`4bb8f979`](https://github.com/commercetools/nodejs/commit/4bb8f979c317bbce1654ca0f1abc9b4717fdda0b) Thanks [@markus-azer](https://github.com/markus-azer)! - types sync-actions: support the following actions `changeInputHint`, `changeEnumValueLabel`, `changeLocalizedEnumValueLabel`. + +## 5.8.0 + +### Minor Changes + +- [#1852](https://github.com/commercetools/nodejs/pull/1852) [`94a376c8`](https://github.com/commercetools/nodejs/commit/94a376c89525b7cee58b710154ddf7cb146cd16c) Thanks [@markus-azer](https://github.com/markus-azer)! - types sync-actions: fix action structure for changeFieldDefinitionOrder + fix internal type sync error by adding optional chaining + +## 5.7.0 + +### Minor Changes + +- [#1850](https://github.com/commercetools/nodejs/pull/1850) [`330cd9a9`](https://github.com/commercetools/nodejs/commit/330cd9a9b4fca045d479d2d220d2a2a2b966b1f4) Thanks [@markus-azer](https://github.com/markus-azer)! - types sync-actions: fix action structure for changeLocalizedEnumValueOrder, changeEnumValueOrder + +## 5.6.0 + +### Minor Changes + +- [#1844](https://github.com/commercetools/nodejs/pull/1844) [`23f0529b`](https://github.com/commercetools/nodejs/commit/23f0529bbf359a11500dbf87bdc9e59cb759c89a) Thanks [@markus-azer](https://github.com/markus-azer)! - Add localizedName action to shipping methods + +## 5.5.0 + +### Minor Changes + +- [#1841](https://github.com/commercetools/nodejs/pull/1841) [`b90c7238`](https://github.com/commercetools/nodejs/commit/b90c7238f0d3d892e1066fd2883cff062b099e66) Thanks [@Rombelirk](https://github.com/Rombelirk)! - Add Custom Fields to Shipping Methods. + +## 5.4.1 + +### Patch Changes + +- [#1839](https://github.com/commercetools/nodejs/pull/1839) [`d6cadcbc`](https://github.com/commercetools/nodejs/commit/d6cadcbc4b850fa6f438b65c3b63b294a32a58ee) Thanks [@tdeekens](https://github.com/tdeekens)! - Fix failing to sync froozen arrays for prices + +## 5.4.0 + +### Minor Changes + +- [#1836](https://github.com/commercetools/nodejs/pull/1836) [`ad34d030`](https://github.com/commercetools/nodejs/commit/ad34d03041e7e6b8284da6224dc968fde537a85a) Thanks [@nicolasnieto92](https://github.com/nicolasnieto92)! - Add setAuthenticationMode sync action + +## 5.3.1 + +### Patch Changes + +- [#1818](https://github.com/commercetools/nodejs/pull/1818) [`856929e3`](https://github.com/commercetools/nodejs/commit/856929e3bc176021a9b52e1ff9c888e51c83cccd) Thanks [@qmateub](https://github.com/qmateub)! - fix(sync-actions/orders): adjust diff calculation of returnInfo items + +## 5.3.0 + +### Minor Changes + +- [#1820](https://github.com/commercetools/nodejs/pull/1820) [`c3964026`](https://github.com/commercetools/nodejs/commit/c3964026b401cb1c8ae8b581a3fcc4ea692ed3b4) Thanks [@danrleyt](https://github.com/danrleyt)! - Adding support to quote requests and staged quotes + +## 5.2.0 + +### Minor Changes + +- [`cad54c42`](https://github.com/commercetools/nodejs/commit/cad54c421e18464ae03fb283a30f2ba2f3f6e46a) Thanks [@qmateub](https://github.com/qmateub)! - feat(sync-actions): improve performance for large arrays comparisons" + +## 5.1.0 + +### Minor Changes + +- [#1803](https://github.com/commercetools/nodejs/pull/1803) [`823985ae`](https://github.com/commercetools/nodejs/commit/823985ae67465673c26f296b68681f255230d571) Thanks [@nicolasnieto92](https://github.com/nicolasnieto92)! - Add createSyncStandalonePrices export to index for supporting prices sync actions + +## 5.0.0 + +### Major Changes + +- [#1775](https://github.com/commercetools/nodejs/pull/1775) [`35669f30`](https://github.com/commercetools/nodejs/commit/35669f30dbc4b24d59ec3df3f38417b1f2a77837) Thanks [@ajimae](https://github.com/ajimae)! - Drop support for Node `v10` and `v12`. Supported versions now are `v14`, `v16` and `v18`. + +## 4.13.0 + +### Minor Changes + +- [#1798](https://github.com/commercetools/nodejs/pull/1798) [`850325d0`](https://github.com/commercetools/nodejs/commit/850325d08603764787c387b2341e4009d0c4f788) Thanks [@markus-azer](https://github.com/markus-azer)! - support standalone prices + +## 4.12.0 + +### Minor Changes + +- [#1796](https://github.com/commercetools/nodejs/pull/1796) [`7aaf91cd`](https://github.com/commercetools/nodejs/commit/7aaf91cdecb7c844943369fc137a5356becdba36) Thanks [@VineetKumarKushwaha](https://github.com/VineetKumarKushwaha)! - Fix custom types sync actions to detect addEnumValue action correctly + +## 4.11.0 + +### Minor Changes + +- [#1788](https://github.com/commercetools/nodejs/pull/1788) [`f1acfb67`](https://github.com/commercetools/nodejs/commit/f1acfb67708d8253f551481fd65097add48c6686) Thanks [@nicolasnieto92](https://github.com/nicolasnieto92)! - Add setPriceMode sync action for commercetools-importer project + +## 4.10.1 + +### Patch Changes + +- [#1770](https://github.com/commercetools/nodejs/pull/1770) [`381d1e1f`](https://github.com/commercetools/nodejs/commit/381d1e1f07cc2705962973e3a48934bf7884e309) Thanks [@mohib0306](https://github.com/mohib0306)! - Fix product selection's name update action. `setName` => `changeName` + Expose `createSyncProductSelections` from `sync-actions` package + +## 4.10.0 + +### Minor Changes + +- [#1767](https://github.com/commercetools/nodejs/pull/1767) [`1aef3423`](https://github.com/commercetools/nodejs/commit/1aef3423e96da7f5df20fd5f66ec29146cacee83) Thanks [@mohib0306](https://github.com/mohib0306)! - feat(sync-actions/product-selections): add sync action support for product selections + + As product selections are available via the API, the sync-actions package is updated to support generating update actions for product selections. diff --git a/packages/sync-actions/README.md b/packages/sync-actions/README.md new file mode 100644 index 000000000..3d0c0ea96 --- /dev/null +++ b/packages/sync-actions/README.md @@ -0,0 +1,11 @@ +# commercetools-sync-actions + +Construct API update actions, for usage with `@commercetools/sdk-client`. + +https://commercetools.github.io/nodejs/sdk/api/syncActions.html + +## Install + +```bash +npm install --save @commercetools/sync-actions +``` diff --git a/packages/sync-actions/package.json b/packages/sync-actions/package.json new file mode 100644 index 000000000..72b6f8e07 --- /dev/null +++ b/packages/sync-actions/package.json @@ -0,0 +1,42 @@ +{ + "name": "@commercetools/sync-actions", + "version": "1.0.0", + "engines": { + "node": ">=14" + }, + "description": "Build API update actions for the commercetools platform.", + "keywords": ["commercetools", "sync", "actions"], + "homepage": "https://commercetools.github.io/nodejs/", + "license": "MIT", + "directories": { + "lib": "lib", + "test": "test" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/commercetools/commercetools-sdk-typescript.git" + }, + "bugs": { + "url": "https://github.com/commercetools/commercetools-sdk-typescript/issues" + }, + "dependencies": { + "fast-equals": "^2.0.0", + "jsondiffpatch": "^0.5.0" + }, + "files": ["dist", "CHANGELOG.md"], + "author": "Nicola Molinari (https://github.com/emmenko)", + "main": "dist/commercetools-sync-actions.cjs.js", + "module": "dist/commercetools-sync-actions.esm.js", + "browser": { + "./dist/commercetools-sync-actions.cjs.js": "./dist/commercetools-sync-actions.browser.cjs.js", + "./dist/commercetools-sync-actions.esm.js": "./dist/commercetools-sync-actions.browser.esm.js" + }, + "scripts": { + "organize_imports": "find src -type f -name '*.ts' | xargs organize-imports-cli", + "postbuild": "yarn organize_imports", + "post_process_generate": "yarn organize_imports" + } +} diff --git a/packages/sync-actions/src/attribute-groups-actions.ts b/packages/sync-actions/src/attribute-groups-actions.ts new file mode 100644 index 000000000..8aff12b26 --- /dev/null +++ b/packages/sync-actions/src/attribute-groups-actions.ts @@ -0,0 +1,70 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { AttributeReference } from '@commercetools/platform-sdk' +import { UpdateAction } from './types/update-actions' + +const hasAttribute = ( + attributes: Array, + newValue: AttributeReference +) => attributes.some((attribute) => attribute.key === newValue.key) + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'setDescription', key: 'description' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapAttributes: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('attributes', { + [ADD_ACTIONS]: (newAttribute) => ({ + action: 'addAttribute', + attribute: newAttribute, + }), + [REMOVE_ACTIONS]: (oldAttribute) => { + // We only add the action if the attribute is not included in the new object. + return !hasAttribute(newObj.attributes, oldAttribute) + ? { + action: 'removeAttribute', + attribute: oldAttribute, + } + : null + }, + [CHANGE_ACTIONS]: (oldAttribute, newAttribute) => { + const result = [] + // We only remove the attribute in case that the oldAttribute is not + // included in the new object + if (!hasAttribute(newObj.attributes, oldAttribute)) + result.push({ + action: 'removeAttribute', + attribute: oldAttribute, + }) + + // We only add the attribute in case that the newAttribute was not + // included in the old object + if (!hasAttribute(oldObj.attributes, newAttribute)) + result.push({ + action: 'addAttribute', + attribute: newAttribute, + }) + + return result + }, + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/attribute-groups.ts b/packages/sync-actions/src/attribute-groups.ts new file mode 100644 index 000000000..9a037ae01 --- /dev/null +++ b/packages/sync-actions/src/attribute-groups.ts @@ -0,0 +1,56 @@ +import { + AttributeGroup, + AttributeGroupUpdateAction, +} from '@commercetools/platform-sdk' + +import { + actionsMapAttributes, + actionsMapBase, +} from './attribute-groups-actions' +import { + ActionGroup, + SyncAction, + SyncActionConfig, + UpdateAction, +} from './types/update-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +const createAttributeGroupsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + allActions.push( + mapActionGroup('attributes', () => + actionsMapAttributes(diff, oldObj, newObj) + ).flat() + ) + return allActions.flat() + } +} + +export const createSyncAttributeGroups = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createAttributeGroupsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + AttributeGroup, + AttributeGroupUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/cart-discounts-actions.ts b/packages/sync-actions/src/cart-discounts-actions.ts new file mode 100644 index 000000000..323ab24d1 --- /dev/null +++ b/packages/sync-actions/src/cart-discounts-actions.ts @@ -0,0 +1,28 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeIsActive', key: 'isActive' }, + { action: 'changeName', key: 'name' }, + { action: 'changeCartPredicate', key: 'cartPredicate' }, + { action: 'changeSortOrder', key: 'sortOrder' }, + { action: 'changeValue', key: 'value' }, + { action: 'changeRequiresDiscountCode', key: 'requiresDiscountCode' }, + { action: 'changeTarget', key: 'target' }, + { action: 'setDescription', key: 'description' }, + { action: 'setValidFrom', key: 'validFrom' }, + { action: 'setValidUntil', key: 'validUntil' }, + { action: 'changeStackingMode', key: 'stackingMode' }, + { action: 'setKey', key: 'key' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/cart-discounts.ts b/packages/sync-actions/src/cart-discounts.ts new file mode 100644 index 000000000..f19a526be --- /dev/null +++ b/packages/sync-actions/src/cart-discounts.ts @@ -0,0 +1,57 @@ +import { + CartDiscount, + CartDiscountUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './cart-discounts-actions' +import { + ActionGroup, + SyncAction, + SyncActionConfig, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import combineValidityActions from './utils/combine-validity-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'custom'] + +const createCartDiscountsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return combineValidityActions(allActions.flat()) + } +} + +export const createSyncCartDiscounts = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createCartDiscountsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + CartDiscount, + CartDiscountUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/categories.ts b/packages/sync-actions/src/categories.ts new file mode 100644 index 000000000..2c7dddbce --- /dev/null +++ b/packages/sync-actions/src/categories.ts @@ -0,0 +1,85 @@ +import { Category, CategoryUpdateAction } from '@commercetools/platform-sdk' +import { actionsMapAssets } from './category-assets-actions' +import { + actionsMapBase, + actionsMapMeta, + actionsMapReferences, +} from './category-actions' +import { + SyncAction, + UpdateAction, + ActionGroup, + SyncActionConfig, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import copyEmptyArrayProps from './utils/copy-empty-array-props' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'references', 'meta', 'custom', 'assets'] + +const createCategoryMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('references', () => + actionsMapReferences(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('meta', () => actionsMapMeta(diff, oldObj, newObj)) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + allActions.push( + mapActionGroup('assets', () => actionsMapAssets(diff, oldObj, newObj)) + ) + + return allActions.flat() + } +} + +export const createSyncCategories = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createCategoryMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions( + diff, + doMapActions, + copyEmptyArrayProps + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/category-actions.ts b/packages/sync-actions/src/category-actions.ts new file mode 100644 index 000000000..f07e1727c --- /dev/null +++ b/packages/sync-actions/src/category-actions.ts @@ -0,0 +1,57 @@ +import { + buildBaseAttributesActions, + buildReferenceActions, +} from './utils/common-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'changeSlug', key: 'slug' }, + { action: 'setDescription', key: 'description' }, + { action: 'changeOrderHint', key: 'orderHint' }, + { action: 'setExternalId', key: 'externalId' }, + { action: 'setKey', key: 'key' }, +] + +export const metaActionsList: Array = [ + { action: 'setMetaTitle', key: 'metaTitle' }, + { action: 'setMetaKeywords', key: 'metaKeywords' }, + { action: 'setMetaDescription', key: 'metaDescription' }, +] + +export const referenceActionsList: Array = [ + { action: 'changeParent', key: 'parent' }, +] + +/** + * SYNC FUNCTIONS + */ + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapReferences: ActionMap = (diff, oldObj, newObj) => { + return buildReferenceActions({ + actions: referenceActionsList, + diff, + oldObj, + newObj, + }) +} + +export const actionsMapMeta: ActionMap = (diff, oldObj, newObj) => { + return buildBaseAttributesActions({ + actions: metaActionsList, + diff, + oldObj, + newObj, + }) +} diff --git a/packages/sync-actions/src/category-assets-actions.ts b/packages/sync-actions/src/category-assets-actions.ts new file mode 100644 index 000000000..b0b1fe9e3 --- /dev/null +++ b/packages/sync-actions/src/category-assets-actions.ts @@ -0,0 +1,47 @@ +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { + CategoryAddAssetAction, + CategoryRemoveAssetAction, +} from '@commercetools/platform-sdk' +import { ActionMap } from './utils/create-map-action-group' + +import { UpdateAction } from './types/update-actions' + +function toAssetIdentifier(asset: { id?: string; key?: string }) { + return asset.id ? { assetId: asset.id } : { assetKey: asset.key } +} + +export const actionsMapAssets: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('assets', { + [ADD_ACTIONS]: (newAsset): CategoryAddAssetAction => ({ + action: 'addAsset', + asset: newAsset, + }), + [REMOVE_ACTIONS]: (oldAsset): CategoryRemoveAssetAction => ({ + action: 'removeAsset', + ...toAssetIdentifier(oldAsset), + }), + [CHANGE_ACTIONS]: (oldAsset, newAsset): Array => + // here we could use more atomic update actions (e.g. changeAssetName) + // but for now we use the simpler approach to first remove and then + // re-add the asset - which reduces the code complexity + { + return [ + { + action: 'removeAsset', + ...toAssetIdentifier(oldAsset), + }, + { + action: 'addAsset', + asset: newAsset, + }, + ] + }, + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/channels-actions.ts b/packages/sync-actions/src/channels-actions.ts new file mode 100644 index 000000000..ffd999729 --- /dev/null +++ b/packages/sync-actions/src/channels-actions.ts @@ -0,0 +1,22 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeKey', key: 'key' }, + { action: 'changeName', key: 'name' }, + { action: 'changeDescription', key: 'description' }, + { action: 'setAddress', key: 'address' }, + { action: 'setGeoLocation', key: 'geoLocation' }, + { action: 'setRoles', key: 'roles' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/channels.ts b/packages/sync-actions/src/channels.ts new file mode 100644 index 000000000..11df85aa3 --- /dev/null +++ b/packages/sync-actions/src/channels.ts @@ -0,0 +1,53 @@ +import { Channel, ChannelUpdateAction } from '@commercetools/platform-sdk' +import { actionsMapBase } from './channels-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'custom'] + +const createChannelsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export const createSyncChannels = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createChannelsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/customer-actions.ts b/packages/sync-actions/src/customer-actions.ts new file mode 100644 index 000000000..442019ef8 --- /dev/null +++ b/packages/sync-actions/src/customer-actions.ts @@ -0,0 +1,231 @@ +import clone, { notEmpty } from './utils/clone' +import { + buildBaseAttributesActions, + buildReferenceActions, + createIsEmptyValue, +} from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { Delta, patch } from './utils/diffpatcher' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +const isEmptyValue = createIsEmptyValue([undefined, null, '']) + +export const baseActionsList: Array = [ + { action: 'setSalutation', key: 'salutation' }, + { action: 'changeEmail', key: 'email' }, + { action: 'setFirstName', key: 'firstName' }, + { action: 'setLastName', key: 'lastName' }, + { action: 'setMiddleName', key: 'middleName' }, + { action: 'setTitle', key: 'title' }, + { action: 'setCustomerNumber', key: 'customerNumber' }, + { action: 'setExternalId', key: 'externalId' }, + { action: 'setCompanyName', key: 'companyName' }, + { action: 'setDateOfBirth', key: 'dateOfBirth' }, + { action: 'setLocale', key: 'locale' }, + { action: 'setVatId', key: 'vatId' }, + { + action: 'setStores', + key: 'stores', + }, + { action: 'setKey', key: 'key' }, +] + +export const setDefaultBaseActionsList: Array = [ + { + action: 'setDefaultBillingAddress', + key: 'defaultBillingAddressId', + actionKey: 'addressId', + }, + { + action: 'setDefaultShippingAddress', + key: 'defaultShippingAddressId', + actionKey: 'addressId', + }, +] + +export const referenceActionsList: Array = [ + { action: 'setCustomerGroup', key: 'customerGroup' }, +] + +export const authenticationModeActionsList: Array = [ + { + action: 'setAuthenticationMode', + key: 'authenticationMode', + value: 'password', + }, +] + +/** + * SYNC FUNCTIONS + */ + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapSetDefaultBase: ActionMapBase = ( + diff, + oldObj, + newObj, + config +) => { + return buildBaseAttributesActions({ + actions: setDefaultBaseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapReferences: ActionMap = (diff, oldObj, newObj) => { + return buildReferenceActions({ + actions: referenceActionsList, + diff, + oldObj, + newObj, + }) +} + +export const actionsMapAddresses: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('addresses', { + [ADD_ACTIONS]: (newObject) => ({ + action: 'addAddress', + address: newObject, + }), + [REMOVE_ACTIONS]: (objectToRemove) => ({ + action: 'removeAddress', + addressId: objectToRemove.id, + }), + [CHANGE_ACTIONS]: (oldObject, updatedObject) => ({ + action: 'changeAddress', + addressId: oldObject.id, + address: updatedObject, + }), + }) + + return handler(diff, oldObj, newObj) +} + +export const actionsMapBillingAddresses: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('billingAddressIds', { + [ADD_ACTIONS]: (addressId) => ({ + action: 'addBillingAddressId', + addressId, + }), + [REMOVE_ACTIONS]: (addressId) => ({ + action: 'removeBillingAddressId', + addressId, + }), + }) + + return handler(diff, oldObj, newObj) +} + +export const actionsMapShippingAddresses: ActionMap = ( + diff, + oldObj, + newObj +) => { + const handler = createBuildArrayActions('shippingAddressIds', { + [ADD_ACTIONS]: (addressId) => ({ + action: 'addShippingAddressId', + addressId, + }), + [REMOVE_ACTIONS]: (addressId) => ({ + action: 'removeShippingAddressId', + addressId, + }), + }) + + return handler(diff, oldObj, newObj) +} + +export const actionsMapAuthenticationModes: ActionMap = ( + diff, + oldObj, + newObj +) => { + return buildAuthenticationModeActions({ + actions: authenticationModeActionsList, + diff, + oldObj, + newObj, + }) +} + +function buildAuthenticationModeActions({ + actions, + diff, + oldObj, + newObj, +}: { + actions: Array + diff: Delta + oldObj: any + newObj: any +}) { + return actions + .map((item) => { + const key = item.key + const value = item.value || item.key + const delta = diff[key] + const before = oldObj[key] + const now = newObj[key] + const isNotDefinedBefore = isEmptyValue(oldObj[key]) + const isNotDefinedNow = isEmptyValue(newObj[key]) + const authenticationModes = ['Password', 'ExternalAuth'] + + if (!delta) return undefined + + if (isNotDefinedNow && isNotDefinedBefore) return undefined + + if (newObj.authenticationMode === 'Password' && !newObj.password) + throw new Error( + 'Cannot set to Password authentication mode without password' + ) + + if ( + 'authenticationMode' in newObj && + !authenticationModes.includes(newObj.authenticationMode) + ) + throw new Error('Invalid Authentication Mode') + + if (!isNotDefinedNow && isNotDefinedBefore) { + // no value previously set + if (newObj.authenticationMode === 'ExternalAuth') + return { action: item.action, authMode: now } + return { action: item.action, authMode: now, [value]: newObj.password } + } + + /* no new value */ + if (isNotDefinedNow && !{}.hasOwnProperty.call(newObj, key)) + return undefined + + if (isNotDefinedNow && {}.hasOwnProperty.call(newObj, key)) + // value unset + return undefined + + // We need to clone `before` as `patch` will mutate it + const patched = patch(clone(before), delta) + if (newObj.authenticationMode === 'ExternalAuth') + return { action: item.action, authMode: patched } + return { + action: item.action, + authMode: patched, + [value]: newObj.password, + } + }) + .filter(notEmpty) +} diff --git a/packages/sync-actions/src/customer-group-actions.ts b/packages/sync-actions/src/customer-group-actions.ts new file mode 100644 index 000000000..44e33d826 --- /dev/null +++ b/packages/sync-actions/src/customer-group-actions.ts @@ -0,0 +1,18 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/customer-group.ts b/packages/sync-actions/src/customer-group.ts new file mode 100644 index 000000000..677ef1e5b --- /dev/null +++ b/packages/sync-actions/src/customer-group.ts @@ -0,0 +1,56 @@ +import { + CustomerGroup, + CustomerGroupUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './customer-group-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'custom'] + +const createCustomerGroupMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export const createSyncCustomerGroup = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createCustomerGroupMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + CustomerGroup, + CustomerGroupUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/customers.ts b/packages/sync-actions/src/customers.ts new file mode 100644 index 000000000..4a1845259 --- /dev/null +++ b/packages/sync-actions/src/customers.ts @@ -0,0 +1,116 @@ +import { Customer, CustomerUpdateAction } from '@commercetools/platform-sdk' +import { + actionsMapAddresses, + actionsMapAuthenticationModes, + actionsMapBase, + actionsMapBillingAddresses, + actionsMapReferences, + actionsMapSetDefaultBase, + actionsMapShippingAddresses, +} from './customer-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import copyEmptyArrayProps from './utils/copy-empty-array-props' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = [ + 'base', + 'references', + 'addresses', + 'custom', + 'authenticationModes', +] + +const createCustomerMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('references', () => + actionsMapReferences(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('addresses', () => + actionsMapAddresses(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('base', () => + actionsMapSetDefaultBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('billingAddressIds', () => + actionsMapBillingAddresses(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('shippingAddressIds', () => + actionsMapShippingAddresses(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + allActions.push( + mapActionGroup('authenticationModes', () => + actionsMapAuthenticationModes(diff, oldObj, newObj) + ) + ) + + return allActions.flat() + } +} + +export const createSyncCustomers = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createCustomerMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions( + diff, + doMapActions, + copyEmptyArrayProps + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/discount-codes-actions.ts b/packages/sync-actions/src/discount-codes-actions.ts new file mode 100644 index 000000000..8a5d836be --- /dev/null +++ b/packages/sync-actions/src/discount-codes-actions.ts @@ -0,0 +1,30 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeIsActive', key: 'isActive' }, + { action: 'setName', key: 'name' }, + { action: 'setDescription', key: 'description' }, + { action: 'setKey', key: 'key' }, + { action: 'setCartPredicate', key: 'cartPredicate' }, + { action: 'setMaxApplications', key: 'maxApplications' }, + { + action: 'setMaxApplicationsPerCustomer', + key: 'maxApplicationsPerCustomer', + }, + { action: 'changeCartDiscounts', key: 'cartDiscounts' }, + { action: 'setValidFrom', key: 'validFrom' }, + { action: 'setValidUntil', key: 'validUntil' }, + { action: 'changeGroups', key: 'groups' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/discount-codes.ts b/packages/sync-actions/src/discount-codes.ts new file mode 100644 index 000000000..8daa85f01 --- /dev/null +++ b/packages/sync-actions/src/discount-codes.ts @@ -0,0 +1,65 @@ +import { + DiscountCode, + DiscountCodeUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './discount-codes-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import combineValidityActions from './utils/combine-validity-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'custom'] + +const createDiscountCodesMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + return combineValidityActions(allActions.flat()) + } +} + +export const createSyncDiscountCodes = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createDiscountCodesMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + DiscountCode, + DiscountCodeUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/index.ts b/packages/sync-actions/src/index.ts new file mode 100644 index 000000000..fc3b85e74 --- /dev/null +++ b/packages/sync-actions/src/index.ts @@ -0,0 +1,26 @@ +export { createSyncAttributeGroups } from './attribute-groups' +export { createSyncCartDiscounts } from './cart-discounts' +export { createSyncCategories } from './categories' +export { createSyncChannels } from './channels' +export { createSyncCustomerGroup } from './customer-group' +export { createSyncCustomers } from './customers' +export { createSyncDiscountCodes } from './discount-codes' +export { createSyncInventories } from './inventories' +export { createSyncOrders } from './orders' +export { createSyncStandalonePrices } from './prices' +export { createSyncProductDiscounts } from './product-discounts' +export { createSyncProductSelections } from './product-selections' +export { createSyncProductTypes } from './product-types' +export { createSyncProducts } from './products' +export { createSyncProjects } from './projects' +export { createSyncQuoteRequest } from './quote-requests' +export { createSyncQuote } from './quotes' +export { createSyncShippingMethods } from './shipping-methods' +export { createSyncStagedQuote } from './staged-quotes' +export { createSyncStates } from './states' +export { createSyncStores } from './stores' +export { createSyncTaxCategories } from './tax-categories' +export { createSyncTypes } from './types' +export { createSyncZones } from './zones' +export { type ActionGroup } from './types/update-actions' +export { type SyncActionConfig } from './types/update-actions' diff --git a/packages/sync-actions/src/inventories.ts b/packages/sync-actions/src/inventories.ts new file mode 100644 index 000000000..35d390502 --- /dev/null +++ b/packages/sync-actions/src/inventories.ts @@ -0,0 +1,70 @@ +import { + InventoryEntry, + InventoryEntryUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase, actionsMapReferences } from './inventory-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'references'] + +const createInventoryMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('references', () => + actionsMapReferences(diff, oldObj, newObj) + ) + ) + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + return allActions.flat() + } +} + +export const createSyncInventories = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createInventoryMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + InventoryEntry, + InventoryEntryUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/inventory-actions.ts b/packages/sync-actions/src/inventory-actions.ts new file mode 100644 index 000000000..b06428b98 --- /dev/null +++ b/packages/sync-actions/src/inventory-actions.ts @@ -0,0 +1,39 @@ +import { + buildBaseAttributesActions, + buildReferenceActions, +} from './utils/common-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeQuantity', key: 'quantityOnStock', actionKey: 'quantity' }, + { action: 'setRestockableInDays', key: 'restockableInDays' }, + { action: 'setExpectedDelivery', key: 'expectedDelivery' }, +] + +export const referenceActionsList: Array = [ + { action: 'setSupplyChannel', key: 'supplyChannel' }, +] + +/** + * SYNC FUNCTIONS + */ + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapReferences: ActionMap = (diff, oldObj, newObj) => { + return buildReferenceActions({ + actions: referenceActionsList, + diff, + oldObj, + newObj, + }) +} diff --git a/packages/sync-actions/src/order-actions.ts b/packages/sync-actions/src/order-actions.ts new file mode 100644 index 000000000..67483a71d --- /dev/null +++ b/packages/sync-actions/src/order-actions.ts @@ -0,0 +1,233 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, +} from './utils/create-build-array-actions' +import { Delta, getDeltaValue } from './utils/diffpatcher' +import extractMatchingPairs from './utils/extract-matching-pairs' +import findMatchingPairs from './utils/find-matching-pairs' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +const REGEX_NUMBER = new RegExp(/^\d+$/) +const REGEX_UNDERSCORE_NUMBER = new RegExp(/^_\d+$/) + +const isAddAction = (key: string, resource: any) => + REGEX_NUMBER.test(key) && Array.isArray(resource) && resource.length + +const isRemoveAction = (key: string, resource: any) => + REGEX_UNDERSCORE_NUMBER.test(key) && Number(resource[2]) === 0 + +export const baseActionsList: Array = [ + { action: 'changeOrderState', key: 'orderState' }, + { action: 'changePaymentState', key: 'paymentState' }, + { action: 'changeShipmentState', key: 'shipmentState' }, +] + +/** + * SYNC FUNCTIONS + */ + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapDeliveries: ActionMap = (diff, oldObj, newObj) => { + const deliveriesDiff = diff.shippingInfo + if (!deliveriesDiff) return [] + + const handler = createBuildArrayActions('deliveries', { + [ADD_ACTIONS]: (newObject) => ({ + action: 'addDelivery', + items: newObject.items, + parcels: newObject.parcels, + }), + }) + + return handler(deliveriesDiff, oldObj.shippingInfo, newObj.shippingInfo) +} + +function _buildDeliveryParcelsAction( + diffedParcels: Delta, + oldDelivery: any = {}, + newDelivery: any = {} +) { + const addParcelActions: Array = [] + const removeParcelActions: Array = [] + + // generate a hashMap to be able to reference the right image from both ends + const matchingParcelPairs = findMatchingPairs( + diffedParcels, + oldDelivery.parcels, + newDelivery.parcels + ) + diffedParcels && + Object.entries(diffedParcels).forEach(([key, parcel]) => { + const { oldObj } = extractMatchingPairs( + matchingParcelPairs, + key, + oldDelivery.parcels, + newDelivery.parcels + ) + + if (isAddAction(key, parcel)) { + addParcelActions.push({ + action: 'addParcelToDelivery', + deliveryId: oldDelivery.id, + ...getDeltaValue(parcel), + }) + return + } + + if (isRemoveAction(key, parcel)) { + removeParcelActions.push({ + action: 'removeParcelFromDelivery', + parcelId: oldObj.id, + }) + } + }) + + return [addParcelActions, removeParcelActions] +} + +function _buildDeliveryItemsAction(diffedItems: any, newDelivery: any = {}) { + const setDeliveryItemsAction: Array = [] + // If there is a diff it means that there were changes (update, adds or removes) + // over the items, which means that `setDeliveryItems` change has happened over + // the delivery + if (diffedItems && Object.keys(diffedItems).length > 0) { + setDeliveryItemsAction.push({ + action: 'setDeliveryItems', + deliveryId: newDelivery.id, + deliveryKey: newDelivery.key, + items: newDelivery.items, + }) + } + + return [setDeliveryItemsAction] +} + +export function actionsMapParcels( + diff: Delta | undefined, + oldObj: any, + newObj: any, + deliveryHashMap: any +) { + const shippingInfo = diff.shippingInfo + if (!shippingInfo) return [] + + const deliveries = shippingInfo.deliveries + if (!deliveries) return [] + + let addParcelActions: Array = [] + let removeParcelActions: Array = [] + + if (deliveries) + Object.entries(deliveries).forEach(([key, delivery]) => { + const { oldObj: oldDelivery, newObj: newDelivery } = extractMatchingPairs( + deliveryHashMap, + key, + oldObj.shippingInfo.deliveries, + newObj.shippingInfo.deliveries + ) + if (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) { + const [addParcelAction, removeParcelAction] = + _buildDeliveryParcelsAction( + (delivery as any).parcels, + oldDelivery, + newDelivery + ) + + addParcelActions = addParcelActions.concat(addParcelAction) + removeParcelActions = removeParcelActions.concat(removeParcelAction) + } + }) + + return removeParcelActions.concat(addParcelActions) +} + +export function actionsMapDeliveryItems( + diff: Delta, + oldObj: any, + newObj: any, + deliveryHashMap: any +) { + const shippingInfo = diff.shippingInfo + if (!shippingInfo) return [] + + const deliveries = shippingInfo.deliveries + if (!deliveries) return [] + + let setDeliveryItemsActions: Array = [] + + Object.entries(deliveries).forEach(([key, delivery]) => { + const { newObj: newDelivery } = extractMatchingPairs( + deliveryHashMap, + key, + oldObj.shippingInfo.deliveries, + newObj.shippingInfo.deliveries + ) + if (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) { + const [setDeliveryItemsAction] = _buildDeliveryItemsAction( + (delivery as any).items, + newDelivery + ) + setDeliveryItemsActions = setDeliveryItemsActions.concat( + setDeliveryItemsAction + ) + } + }) + + return setDeliveryItemsActions +} + +export const actionsMapReturnsInfo: ActionMap = (diff, oldObj, newObj) => { + const returnInfoDiff = diff.returnInfo + if (!returnInfoDiff) return [] + + const handler = createBuildArrayActions('returnInfo', { + [ADD_ACTIONS]: (newReturnInfo) => { + if (newReturnInfo.items) { + return [ + { + action: 'addReturnInfo', + ...newReturnInfo, + }, + ] + } + return [] + }, + [CHANGE_ACTIONS]: (oldSReturnInfo, newReturnInfo, key) => { + const { items = {} } = returnInfoDiff[key] + if (Object.keys(items).length === 0) { + return [] + } + return Object.keys(items).reduce((actions, index) => { + const item = newReturnInfo.items[index] + if (items[index].shipmentState) { + actions.push({ + action: 'setReturnShipmentState', + returnItemId: item.id, + shipmentState: item.shipmentState, + }) + } + if (items[index].paymentState) { + actions.push({ + action: 'setReturnPaymentState', + returnItemId: item.id, + paymentState: item.paymentState, + }) + } + return actions + }, []) + }, + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/orders.ts b/packages/sync-actions/src/orders.ts new file mode 100644 index 000000000..63c2b013c --- /dev/null +++ b/packages/sync-actions/src/orders.ts @@ -0,0 +1,114 @@ +import { + CustomFields, + Order, + OrderUpdateAction, + ReturnInfo, + ShippingInfo, + StagedOrderUpdateAction, +} from '@commercetools/platform-sdk' +import { + actionsMapBase, + actionsMapDeliveries, + actionsMapDeliveryItems, + actionsMapParcels, + actionsMapReturnsInfo, +} from './order-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import findMatchingPairs from './utils/find-matching-pairs' + +export const actionGroups = ['base', 'deliveries'] + +const createOrderMapActions: MapAction = (mapActionGroup, syncActionConfig) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + let deliveryHashMap: any + + if (diff.shippingInfo && diff.shippingInfo.deliveries) { + deliveryHashMap = findMatchingPairs( + diff.shippingInfo.deliveries, + oldObj.shippingInfo.deliveries, + newObj.shippingInfo.deliveries + ) + } + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('deliveries', () => + actionsMapDeliveries(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('parcels', () => + actionsMapParcels(diff, oldObj, newObj, deliveryHashMap) + ) + ) + + allActions.push( + mapActionGroup('items', () => + actionsMapDeliveryItems(diff, oldObj, newObj, deliveryHashMap) + ) + ) + + allActions.push( + mapActionGroup('returnInfo', () => + actionsMapReturnsInfo(diff, oldObj, newObj) + ).flat() + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export type OrderSync = { + orderState: string + paymentState: string + shipmentState: string + shippingInfo: ShippingInfo + returnInfo: Array + custom: CustomFields +} + +export const createSyncOrders = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createOrderMapActions(mapActionGroup, syncActionConfig) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/prices-actions.ts b/packages/sync-actions/src/prices-actions.ts new file mode 100644 index 000000000..afc485fbd --- /dev/null +++ b/packages/sync-actions/src/prices-actions.ts @@ -0,0 +1,24 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeValue', key: 'value' }, + { action: 'setDiscountedPrice', key: 'discounted' }, + // TODO: Later add more accurate actions `addPriceTier`, `removePriceTier` + { action: 'setPriceTiers', key: 'tiers' }, + { action: 'setKey', key: 'key' }, + { action: 'setValidFrom', key: 'validFrom' }, + { action: 'setValidUntil', key: 'validUntil' }, + { action: 'changeActive', key: 'active' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/prices.ts b/packages/sync-actions/src/prices.ts new file mode 100644 index 000000000..44a479c62 --- /dev/null +++ b/packages/sync-actions/src/prices.ts @@ -0,0 +1,50 @@ +import { actionsMapBase } from './prices-actions' +import actionsMapCustom from './utils/action-map-custom' +import combineValidityActions from './utils/combine-validity-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import { + ActionGroup, + SyncAction, + SyncActionConfig, +} from './types/update-actions' +import { + StandalonePrice, + StandalonePriceUpdateAction, +} from '@commercetools/platform-sdk' + +const actionGroups = ['base', 'custom'] + +const createPriceMapActions: MapAction = (mapActionGroup, syncActionConfig) => { + return function doMapActions(diff, newObj, oldObj) { + const baseActions = mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + + const customActions = mapActionGroup('custom', () => + actionsMapCustom(diff, newObj, oldObj) + ) + + return combineValidityActions([...baseActions, ...customActions]) + } +} + +export const createSyncStandalonePrices = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createPriceMapActions(mapActionGroup, syncActionConfig) + + const buildActions = createBuildActions< + StandalonePrice, + StandalonePriceUpdateAction + >(diff, doMapActions) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/product-actions.ts b/packages/sync-actions/src/product-actions.ts new file mode 100644 index 000000000..2840a034a --- /dev/null +++ b/packages/sync-actions/src/product-actions.ts @@ -0,0 +1,901 @@ +import { + ProductAddToCategoryAction, + ProductVariant, +} from '@commercetools/platform-sdk/src' +import actionsMapCustom from './utils/action-map-custom' +import { + buildBaseAttributesActions, + buildReferenceActions, +} from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { Delta, getDeltaValue } from './utils/diffpatcher' +import extractMatchingPairs from './utils/extract-matching-pairs' +import findMatchingPairs from './utils/find-matching-pairs' +import { + Asset, + ProductRemoveFromCategoryAction, +} from '@commercetools/platform-sdk' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +const REGEX_NUMBER = new RegExp(/^\d+$/) +const REGEX_UNDERSCORE_NUMBER = new RegExp(/^_\d+$/) + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'changeSlug', key: 'slug' }, + { action: 'setDescription', key: 'description' }, + { action: 'setSearchKeywords', key: 'searchKeywords' }, + { action: 'setKey', key: 'key' }, + { action: 'setPriceMode', key: 'priceMode' }, +] + +export const baseAssetActionsList: Array = [ + { action: 'setAssetKey', key: 'key', actionKey: 'assetKey' }, + { action: 'changeAssetName', key: 'name' }, + { action: 'setAssetDescription', key: 'description' }, + { action: 'setAssetTags', key: 'tags' }, + { action: 'setAssetSources', key: 'sources' }, +] + +export const metaActionsList: Array = [ + { action: 'setMetaTitle', key: 'metaTitle' }, + { action: 'setMetaDescription', key: 'metaDescription' }, + { action: 'setMetaKeywords', key: 'metaKeywords' }, +] + +export const referenceActionsList: Array = [ + { action: 'setTaxCategory', key: 'taxCategory' }, + { action: 'transitionState', key: 'state' }, +] + +/** + * HELPER FUNCTIONS + */ + +const getIsAddAction = (key: string, resource: any) => + REGEX_NUMBER.test(key) && Array.isArray(resource) && resource.length + +const getIsUpdateAction = (key: string, resource: any) => + REGEX_NUMBER.test(key) && Object.keys(resource).length + +const getIsRemoveAction = (key: string, resource: any) => + REGEX_UNDERSCORE_NUMBER.test(key) && Number(resource[2]) === 0 + +const getIsItemMovedAction = (key: string, resource: any) => + REGEX_UNDERSCORE_NUMBER.test(key) && Number(resource[2]) === 3 + +function _buildSkuActions(variantDiff: Delta, oldVariant: any) { + if ({}.hasOwnProperty.call(variantDiff, 'sku')) { + const newValue = getDeltaValue(variantDiff.sku) + if (!newValue && !oldVariant.sku) return null + + return { + action: 'setSku', + variantId: oldVariant.id, + sku: newValue || null, + } + } + return null +} + +function _buildKeyActions(variantDiff: Delta, oldVariant: any) { + if ({}.hasOwnProperty.call(variantDiff, 'key')) { + const newValue = getDeltaValue(variantDiff.key) + if (!newValue && !oldVariant.key) return null + + return { + action: 'setProductVariantKey', + variantId: oldVariant.id, + key: newValue || null, + } + } + return null +} + +function _buildNewSetAttributeAction( + id: any, + el: any, + sameForAllAttributeNames: Array +) { + const attributeName = el && el.name + if (!attributeName) return undefined + + let action = { + action: 'setAttribute', + variantId: id, + name: attributeName, + value: el.value, + } + + if (sameForAllAttributeNames.indexOf(attributeName) !== -1) { + action = { ...action, action: 'setAttributeInAllVariants' } + delete action.variantId + } + + return action +} + +function _buildSetAttributeAction( + diffedValue: any, + oldVariant: any, + attribute: any, + sameForAllAttributeNames: Array +) { + if (!attribute) return undefined + + let action: any = { + action: 'setAttribute', + variantId: oldVariant.id, + name: attribute.name, + } + + // Used as original object for patching long diff text + const oldAttribute = + oldVariant.attributes.find((a: any) => a.name === attribute.name) || {} + + if (sameForAllAttributeNames.indexOf(attribute.name) !== -1) { + action = { ...action, action: 'setAttributeInAllVariants' } + delete action.variantId + } + + if (Array.isArray(diffedValue)) + action.value = getDeltaValue(diffedValue, oldAttribute.value) + else if (typeof diffedValue === 'string') + // LText: value: {en: "", de: ""} + // Enum: value: {key: "foo", label: "Foo"} + // LEnum: value: {key: "foo", label: {en: "Foo", de: "Foo"}} + // Money: value: {centAmount: 123, currencyCode: ""} + // *: value: "" + + // normal + action.value = getDeltaValue(diffedValue, oldAttribute.value) + else if (diffedValue.centAmount || diffedValue.currencyCode) + // Money + action.value = { + centAmount: diffedValue.centAmount + ? getDeltaValue(diffedValue.centAmount) + : attribute.value.centAmount, + currencyCode: diffedValue.currencyCode + ? getDeltaValue(diffedValue.currencyCode) + : attribute.value.currencyCode, + } + else if (diffedValue.key) + // Enum / LEnum (use only the key) + action.value = getDeltaValue(diffedValue.key) + else if (typeof diffedValue === 'object') + if ({}.hasOwnProperty.call(diffedValue, '_t') && diffedValue._t === 'a') { + // set-typed attribute + action = { ...action, value: attribute.value } + } else { + // LText + + const updatedValue = Object.keys(diffedValue).reduce( + (acc, lang) => { + const patchedValue = getDeltaValue(diffedValue[lang], acc[lang]) + return Object.assign(acc, { [lang]: patchedValue }) + }, + { ...oldAttribute.value } + ) + + action.value = updatedValue + } + + return action +} + +function _buildVariantImagesAction( + diffedImages: any, + oldVariant: any = {}, + newVariant: any = {} +) { + const actions: Array = [] + // generate a hashMap to be able to reference the right image from both ends + const matchingImagePairs = findMatchingPairs( + diffedImages, + oldVariant.images, + newVariant.images, + 'url' + ) + diffedImages && + Object.entries(diffedImages).forEach(([key, image]) => { + const { oldObj, newObj } = extractMatchingPairs( + matchingImagePairs, + key, + oldVariant.images, + newVariant.images + ) + if (REGEX_NUMBER.test(key)) { + // New image + if (Array.isArray(image) && image.length) + actions.push({ + action: 'addExternalImage', + variantId: oldVariant.id, + image: getDeltaValue(image), + }) + else if (typeof image === 'object') + if ( + {}.hasOwnProperty.call(image, 'url') && + (image as any).url.length === 2 + ) { + // There is a new image, remove the old one first. + actions.push({ + action: 'removeImage', + variantId: oldVariant.id, + imageUrl: oldObj.url, + }) + actions.push({ + action: 'addExternalImage', + variantId: oldVariant.id, + image: newObj, + }) + } else if ( + {}.hasOwnProperty.call(image, 'label') && + ((image as any).label.length === 1 || + (image as any).label.length === 2) + ) + actions.push({ + action: 'setImageLabel', + variantId: oldVariant.id, + imageUrl: oldObj.url, + label: getDeltaValue((image as any).label), + }) + } else if (REGEX_UNDERSCORE_NUMBER.test(key)) + if (Array.isArray(image) && image.length === 3) { + if (Number(image[2]) === 3) + // image position changed + actions.push({ + action: 'moveImageToPosition', + variantId: oldVariant.id, + imageUrl: oldObj.url, + position: Number(image[1]), + }) + else if (Number(image[2]) === 0) + // image removed + actions.push({ + action: 'removeImage', + variantId: oldVariant.id, + imageUrl: oldObj.url, + }) + } + }) + + return actions +} + +function _buildVariantPricesAction( + diffedPrices: any, + oldVariant: any = {}, + newVariant: any = {}, + enableDiscounted = false +) { + const addPriceActions: Array = [] + const changePriceActions: Array = [] + const removePriceActions: Array = [] + + // generate a hashMap to be able to reference the right image from both ends + const matchingPricePairs = findMatchingPairs( + diffedPrices, + oldVariant.prices, + newVariant.prices + ) + diffedPrices && + Object.entries(diffedPrices).forEach(([key, price]) => { + const { oldObj, newObj } = extractMatchingPairs( + matchingPricePairs, + key, + oldVariant.prices, + newVariant.prices + ) + if (getIsAddAction(key, price)) { + // Remove read-only fields + const patchedPrice = (price as any).map((p: any) => { + const shallowClone = { ...p } + if (enableDiscounted !== true) delete shallowClone.discounted + return shallowClone + }) + + addPriceActions.push({ + action: 'addPrice', + variantId: oldVariant.id, + price: getDeltaValue(patchedPrice), + }) + return + } + + if (getIsUpdateAction(key, price)) { + // Remove the discounted field and make sure that the price + // still has other values, otherwise simply return + const filteredPrice = { ...(price as any) } + if (enableDiscounted !== true) delete filteredPrice.discounted + if (Object.keys(filteredPrice).length) { + // At this point price should have changed, simply pick the new one + const newPrice = { ...newObj } + if (enableDiscounted !== true) delete newPrice.discounted + + changePriceActions.push({ + action: 'changePrice', + priceId: oldObj.id, + price: newPrice, + }) + } + return + } + + if (getIsRemoveAction(key, price)) { + // price removed + removePriceActions.push({ + action: 'removePrice', + priceId: oldObj.id, + }) + } + }) + + return [addPriceActions, changePriceActions, removePriceActions] +} + +function _buildVariantAttributesActions( + attributes: any, + oldVariant: any, + newVariant: any, + sameForAllAttributeNames: Array +) { + const actions: Array = [] + + if (!attributes) return actions + attributes && + Object.entries(attributes).forEach(([key, value]) => { + if (REGEX_NUMBER.test(key)) { + if (Array.isArray(value)) { + const { id } = oldVariant + const deltaValue = getDeltaValue(value) + const setAction = _buildNewSetAttributeAction( + id, + deltaValue, + sameForAllAttributeNames + ) + + if (setAction) actions.push(setAction) + } else if (newVariant.attributes) { + const setAction = _buildSetAttributeAction( + (value as any).value, + oldVariant, + newVariant.attributes[key], + sameForAllAttributeNames + ) + if (setAction) actions.push(setAction) + } + } else if (REGEX_UNDERSCORE_NUMBER.test(key)) + if (Array.isArray(value)) { + // Ignore pure array moves! + if (value.length === 3 && value[2] === 3) return + + const { id } = oldVariant + + let deltaValue = getDeltaValue(value) + if (!deltaValue) + if (value[0] && value[0].name) + // unset attribute if + deltaValue = { name: value[0].name } + else deltaValue = undefined + + const setAction = _buildNewSetAttributeAction( + id, + deltaValue, + sameForAllAttributeNames + ) + + if (setAction) actions.push(setAction) + } else { + const index = key.substring(1) + if (newVariant.attributes) { + const setAction = _buildSetAttributeAction( + (value as any).value, + oldVariant, + newVariant.attributes[index], + sameForAllAttributeNames + ) + if (setAction) actions.push(setAction) + } + } + }) + + return actions +} + +function toAssetIdentifier(asset: Asset) { + const assetIdentifier = asset.id + ? { assetId: asset.id } + : { assetKey: asset.key } + return assetIdentifier +} + +function toVariantIdentifier(variant: ProductVariant) { + const { id, sku } = variant + return id ? { variantId: id } : { sku } +} + +function _buildVariantChangeAssetOrderAction( + diffAssets: any, + oldVariant: any, + newVariant: any +) { + const isAssetOrderChanged = Object.entries(diffAssets).find((entry) => + getIsItemMovedAction(entry[0], entry[1]) + ) + if (!isAssetOrderChanged) { + return [] + } + const assetIdsBefore = oldVariant.assets.map((value: any) => value.id) + const assetIdsCurrent = newVariant.assets + .map((value: any) => value.id) + .filter((value: any) => value !== undefined) + const assetIdsToKeep = assetIdsCurrent.filter((value: any) => + assetIdsBefore.includes(value) + ) + const assetIdsToRemove = assetIdsBefore.filter( + (item: any) => !assetIdsToKeep.includes(item) + ) + const changeAssetOrderAction = { + action: 'changeAssetOrder', + assetOrder: assetIdsToKeep.concat(assetIdsToRemove), + ...toVariantIdentifier(oldVariant), + } + return [changeAssetOrderAction] +} + +function _buildVariantAssetsActions( + diffAssets: any, + oldVariant: any, + newVariant: any +) { + const assetActions: Array = [] + + // generate a hashMap to be able to reference the right asset from both ends + const matchingAssetPairs = findMatchingPairs( + diffAssets, + oldVariant.assets, + newVariant.assets + ) + + diffAssets && + Object.entries(diffAssets).forEach(([key, asset]) => { + const { oldObj: oldAsset, newObj: newAsset } = extractMatchingPairs( + matchingAssetPairs, + key, + oldVariant.assets, + newVariant.assets + ) + + if (getIsAddAction(key, asset)) { + assetActions.push({ + action: 'addAsset', + asset: getDeltaValue(asset), + ...toVariantIdentifier(newVariant), + position: Number(key), + }) + return + } + + if (getIsUpdateAction(key, asset)) { + // todo add changeAssetOrder + const basicActions = buildBaseAttributesActions({ + actions: baseAssetActionsList, + diff: asset, + oldObj: oldAsset, + newObj: newAsset, + }).map((action) => { + // in case of 'setAssetKey' then the identifier will be only 'assetId' + if (action.action === 'setAssetKey') { + return { + ...action, + ...toVariantIdentifier(oldVariant), + assetId: oldAsset.id, + } + } + + return { + ...action, + ...toVariantIdentifier(oldVariant), + ...toAssetIdentifier(oldAsset), + } + }) + assetActions.push(...basicActions) + + if ((asset as any).custom) { + const customActions = actionsMapCustom(asset, newAsset, oldAsset, { + actions: { + setCustomType: 'setAssetCustomType', + setCustomField: 'setAssetCustomField', + }, + ...toVariantIdentifier(oldVariant), + ...toAssetIdentifier(oldAsset), + }) + assetActions.push(...customActions) + } + + return + } + + if (getIsRemoveAction(key, asset)) { + assetActions.push({ + action: 'removeAsset', + ...toAssetIdentifier(oldAsset), + ...toVariantIdentifier(oldVariant), + }) + } + }) + + const changedAssetOrderAction = _buildVariantChangeAssetOrderAction( + diffAssets, + oldVariant, + newVariant + ) + return [...changedAssetOrderAction, ...assetActions] +} + +/** + * SYNC FUNCTIONS + */ + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export function actionsMapMeta(diff: Delta, oldObj: any, newObj: any) { + return buildBaseAttributesActions({ + actions: metaActionsList, + diff, + oldObj, + newObj, + }) +} + +export function actionsMapAddVariants(diff: Delta, oldObj: any, newObj: any) { + const handler = createBuildArrayActions('variants', { + [ADD_ACTIONS]: (newObject: any) => ({ + ...newObject, + action: 'addVariant', + }), + }) + return handler(diff, oldObj, newObj) +} + +export function actionsMapRemoveVariants( + diff: Delta, + oldObj: any, + newObj: any +) { + const handler = createBuildArrayActions('variants', { + [REMOVE_ACTIONS]: ({ id }: { id: string }) => ({ + action: 'removeVariant', + id, + }), + }) + return handler(diff, oldObj, newObj) +} + +export function actionsMapReferences(diff: Delta, oldObj: any, newObj: any) { + return buildReferenceActions({ + actions: referenceActionsList, + diff, + oldObj, + newObj, + }) +} + +export function actionsMapCategories(diff: { + categories?: { [key: string]: any } + [key: string]: any +}): Array { + const actions: Array = [] + if (!diff.categories) return actions + + const addToCategoryActions: Array = [] + const removeFromCategoryActions: Array = [] + + Object.entries(diff.categories).forEach(([key, category]) => { + if (Array.isArray(category)) { + const action: any = { category: category[0] } + + if (category.length === 3) { + // Ignore pure array moves! + if (category[2] !== 3) { + action.action = 'removeFromCategory' + removeFromCategoryActions.push(action) + } + } else if (category.length === 1) { + action.action = 'addToCategory' + addToCategoryActions.push(action) + } + } + }) + + // Make sure `removeFromCategory` actions come first + return [...removeFromCategoryActions, ...addToCategoryActions] +} + +export function actionsMapCategoryOrderHints(diff: Delta) { + if (!diff.categoryOrderHints) return [] + // Ignore this pattern as its means no changes happened [{},0,0] + if (Array.isArray(diff.categoryOrderHints)) return [] + + return Object.keys(diff.categoryOrderHints).map((categoryId) => { + const hintChange = diff.categoryOrderHints[categoryId] + + const action: any = { + action: 'setCategoryOrderHint', + categoryId, + } + + if (hintChange.length === 1) + // item was added + action.orderHint = hintChange[0] + else if (hintChange.length === 2 && hintChange[1] !== 0) + // item was changed + action.orderHint = hintChange[1] + + // else item was removed -> do not set 'orderHint' property + + return action + }) +} + +export function actionsMapAssets( + diff: Delta, + oldObj: any, + newObj: any, + variantHashMap: any +) { + let allAssetsActions: Array = [] + + const { variants } = diff + + if (variants) + Object.entries(variants as { [key: string]: ProductVariant }).forEach( + ([key, variant]) => { + const { oldObj: oldVariant, newObj: newVariant } = extractMatchingPairs( + variantHashMap, + key, + oldObj.variants, + newObj.variants + ) + if ( + variant.assets && + (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) + ) { + const assetActions = _buildVariantAssetsActions( + variant.assets, + oldVariant, + newVariant + ) + + allAssetsActions = allAssetsActions.concat(assetActions) + } + } + ) + + return allAssetsActions +} + +export function actionsMapAttributes( + diff: Delta, + oldObj: any, + newObj: any, + sameForAllAttributeNames: Array = [], + variantHashMap: any +) { + let actions: Array = [] + const { variants } = diff + + if (variants) + Object.entries(variants).forEach(([key, variant]) => { + const { oldObj: oldVariant, newObj: newVariant } = extractMatchingPairs( + variantHashMap, + key, + oldObj.variants, + newObj.variants + ) + if (REGEX_NUMBER.test(key) && !Array.isArray(variant)) { + const skuAction = _buildSkuActions(variant, oldVariant) + const keyAction = _buildKeyActions(variant, oldVariant) + if (skuAction) actions.push(skuAction) + if (keyAction) actions.push(keyAction) + + const { attributes } = variant as any + + const attrActions = _buildVariantAttributesActions( + attributes, + oldVariant, + newVariant, + sameForAllAttributeNames + ) + actions = actions.concat(attrActions) + } + }) + + // Ensure that an action is unique. + // This is especially necessary for SFA attributes. + return actions.filter( + (b, index, self) => + index === + self.findIndex( + (a) => + a.action === b.action && + a.name === b.name && + a.variantId === b.variantId + ) + ) +} + +export function actionsMapImages( + diff: Delta, + oldObj: any, + newObj: any, + variantHashMap: any +) { + let actions: Array = [] + const { variants } = diff + if (variants) + Object.entries(variants as { [key: string]: ProductVariant }).forEach( + ([key, variant]) => { + const { oldObj: oldVariant, newObj: newVariant } = extractMatchingPairs( + variantHashMap, + key, + oldObj.variants, + newObj.variants + ) + if (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) { + const vActions = _buildVariantImagesAction( + variant.images, + oldVariant, + newVariant + ) + actions = actions.concat(vActions) + } + } + ) + + return actions +} + +export function actionsMapPrices( + diff: Delta, + oldObj: any, + newObj: any, + variantHashMap: any, + enableDiscounted: any +) { + let addPriceActions: Array = [] + let changePriceActions: Array = [] + let removePriceActions: Array = [] + + const { variants } = diff + + if (variants) + Object.entries(variants as { [key: string]: ProductVariant }).forEach( + ([key, variant]) => { + const { oldObj: oldVariant, newObj: newVariant } = extractMatchingPairs( + variantHashMap, + key, + oldObj.variants, + newObj.variants + ) + if (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) { + const [addPriceAction, changePriceAction, removePriceAction] = + _buildVariantPricesAction( + variant.prices, + oldVariant, + newVariant, + enableDiscounted + ) + + addPriceActions = addPriceActions.concat(addPriceAction) + changePriceActions = changePriceActions.concat(changePriceAction) + removePriceActions = removePriceActions.concat(removePriceAction) + } + } + ) + + // price actions need to be in this below order + return changePriceActions.concat(removePriceActions).concat(addPriceActions) +} + +export function actionsMapPricesCustom( + diff: Delta, + oldObj: any, + newObj: any, + variantHashMap: any +) { + let actions: Array = [] + + const { variants } = diff + + if (variants) + Object.entries(variants as { [key: string]: ProductVariant }).forEach( + ([key, variant]) => { + const { oldObj: oldVariant, newObj: newVariant } = extractMatchingPairs( + variantHashMap, + key, + oldObj.variants, + newObj.variants + ) + + if ( + variant && + variant.prices && + (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) + ) { + const priceHashMap = findMatchingPairs( + variant.prices, + oldVariant.prices, + newVariant.prices + ) + + Object.entries(variant.prices).forEach(([key, price]) => { + const { oldObj: oldPrice, newObj: newPrice } = extractMatchingPairs( + priceHashMap, + key, + oldVariant.prices, + newVariant.prices + ) + + if ( + price.custom && + (REGEX_UNDERSCORE_NUMBER.test(key) || REGEX_NUMBER.test(key)) + ) { + const generatedActions = actionsMapCustom( + price, + newPrice, + oldPrice, + { + actions: { + setCustomType: 'setProductPriceCustomType', + setCustomField: 'setProductPriceCustomField', + }, + priceId: oldPrice.id, + } + ) + + actions = actions.concat(generatedActions) + } + }) + } + } + ) + + return actions +} + +export function actionsMapMasterVariant( + oldObj: any, + newObj: any +): Array { + const createChangeMasterVariantAction = (variantId: string) => ({ + action: 'changeMasterVariant', + variantId, + }) + const extractMasterVariantId = (fromObj: any) => { + const variants = Array.isArray(fromObj.variants) ? fromObj.variants : [] + + return variants[0] ? variants[0].id : undefined + } + + const newMasterVariantId = extractMasterVariantId(newObj) + const oldMasterVariantId = extractMasterVariantId(oldObj) + + // Old and new master master variant differ and a new master variant id exists + if (newMasterVariantId && oldMasterVariantId !== newMasterVariantId) + return [createChangeMasterVariantAction(newMasterVariantId)] + + return [] +} diff --git a/packages/sync-actions/src/product-discounts-actions.ts b/packages/sync-actions/src/product-discounts-actions.ts new file mode 100644 index 000000000..1fdf74dc5 --- /dev/null +++ b/packages/sync-actions/src/product-discounts-actions.ts @@ -0,0 +1,25 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeIsActive', key: 'isActive' }, + { action: 'changeName', key: 'name' }, + { action: 'changePredicate', key: 'predicate' }, + { action: 'changeSortOrder', key: 'sortOrder' }, + { action: 'changeValue', key: 'value' }, + { action: 'setDescription', key: 'description' }, + { action: 'setValidFrom', key: 'validFrom' }, + { action: 'setValidUntil', key: 'validUntil' }, + { action: 'setKey', key: 'key' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/product-discounts.ts b/packages/sync-actions/src/product-discounts.ts new file mode 100644 index 000000000..1e0d8cacd --- /dev/null +++ b/packages/sync-actions/src/product-discounts.ts @@ -0,0 +1,51 @@ +import { + ProductDiscount, + ProductDiscountUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './product-discounts-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import combineValidityActions from './utils/combine-validity-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base'] + +const createProductDiscountsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + return combineValidityActions(allActions.flat()) + } +} + +export const createSyncProductDiscounts = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createProductDiscountsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + ProductDiscount, + ProductDiscountUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/product-selections-actions.ts b/packages/sync-actions/src/product-selections-actions.ts new file mode 100644 index 000000000..47aaf1f37 --- /dev/null +++ b/packages/sync-actions/src/product-selections-actions.ts @@ -0,0 +1,17 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + }) +} diff --git a/packages/sync-actions/src/product-selections.ts b/packages/sync-actions/src/product-selections.ts new file mode 100644 index 000000000..cb8b34b83 --- /dev/null +++ b/packages/sync-actions/src/product-selections.ts @@ -0,0 +1,60 @@ +import { + ProductSelection, + ProductSelectionUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './product-selections-actions' +import { ActionGroup, SyncAction, UpdateAction } from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup from './utils/create-map-action-group' +import { Delta, diff } from './utils/diffpatcher' + +export const actionGroups = ['base'] + +function createProductSelectionsMapActions( + mapActionGroup: ( + type: string, + fn: () => Array + ) => Array +): ( + diff: Delta, + next: any, + previous: any, + options: any +) => Array { + return function doMapActions( + diff: Delta, + next: any, + previous: any + ): Array { + const allActions = [] + allActions.push( + mapActionGroup( + 'base', + (): Array => actionsMapBase(diff, previous, next) + ) + ) + allActions.push( + mapActionGroup( + 'custom', + (): Array => actionsMapCustom(diff, next, previous) + ) + ) + + return allActions.flat() + } +} + +export const createSyncProductSelections = ( + actionGroupList?: Array +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createProductSelectionsMapActions(mapActionGroup) + const onBeforeApplyingDiff: any = null + const buildActions = createBuildActions< + ProductSelection, + ProductSelectionUpdateAction + >(diff, doMapActions, onBeforeApplyingDiff) + + return { buildActions } +} diff --git a/packages/sync-actions/src/product-types-actions.ts b/packages/sync-actions/src/product-types-actions.ts new file mode 100644 index 000000000..1bd63c470 --- /dev/null +++ b/packages/sync-actions/src/product-types-actions.ts @@ -0,0 +1,343 @@ +import { LocalizedString } from '@commercetools/platform-sdk' +import { deepEqual } from 'fast-equals' +import { + buildBaseAttributesActions, + createIsEmptyValue, +} from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'changeDescription', key: 'description' }, +] + +export const actionsMapBase: ActionMapBase = (diff, previous, next, config) => { + // when `diff` is undefined, then the underlying `buildActions` has returned any diff + // which given in product-types would mean that `buildActions` has run with `nestedValuesChanges` applied + // To allow continuation of update-action generation, we let this pass.. + if (!diff) return [] + return buildBaseAttributesActions({ + diff, + actions: baseActionsList, + oldObj: previous, + newObj: next, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +// this is nearly similar to `buildBaseAttributesActions`, however with a significant difference. +// `buildBasAttributesActions` generates update-actions with help of `diff`, +// which is an object consisting of flags which indicates different operations. +// `generateBaseFieldsUpdateActions` only generate based on `previous` and `next`. +export const generateBaseFieldsUpdateActions = ( + previous: any, + next: any, + actionDefinition: { [s: string]: { action: string; attributeName?: string } } +) => { + const isEmpty = createIsEmptyValue([undefined, null, '']) + return Object.entries(actionDefinition).reduce( + (nextUpdateActions, [field, actionFieldDefinition]) => { + if (isEmpty(previous[field]) && isEmpty(next[field])) + return nextUpdateActions + if (!isEmpty(previous[field]) && isEmpty(next[field])) + return [...nextUpdateActions, actionFieldDefinition] + if (!deepEqual(previous[field], next[field])) { + switch (field) { + // BEWARE that this is generates update-action only for key of enum attribute value, + // not key of product type. If we need to re-factor `product-types` sync actions to use + // `generateBaseFieldsUpdateActions`, we need to extract the following logic so we could + // cover both entity types. + case 'key': + return [ + ...nextUpdateActions, + // Another option is to have explicit name of `field` e.g `enumKey`, which we could use to + // generate appropriate update-action for respective entity type. + // An outline of this on the top of my head: + // ```js + // case 'enumKey': + // return [ + // ...nextUpdateActions, + // { + // action: actionFieldDefinition.action, + // attributeName: actionFieldDefinition.attributeName, + // key: previous.key, + // newKey: next.key, + // }, + // ] + // case 'productTypeKey': + // return [ + // ...nextUpdateActions, + // { + // action: actionFieldDefinition.action, + // key: next.key + // }, + // ] + // ``` + { + action: actionFieldDefinition.action, + attributeName: actionFieldDefinition.attributeName, + key: previous[field], + newKey: next[field], + }, + ] + // attribute + case 'attributeConstraint': + case 'inputHint': + return [ + ...nextUpdateActions, + { + action: actionFieldDefinition.action, + attributeName: actionFieldDefinition.attributeName, + newValue: next[field], + }, + ] + default: + return [ + ...nextUpdateActions, + { + action: actionFieldDefinition.action, + attributeName: actionFieldDefinition.attributeName, + [field]: next[field], + }, + ] + } + } + return nextUpdateActions + }, + [] + ) +} + +type AttributeDefinition = { + previous?: { name: string } | undefined + next?: { name: string } | undefined + hint?: { attributeName: string; isLocalized: boolean } +} + +const generateUpdateActionsForAttributeDefinitions = ( + attributeDefinitions: Array = [] +) => { + const removedAttributeDefinitions = attributeDefinitions.filter( + (attributeDefinition) => + attributeDefinition.previous && !attributeDefinition.next + ) + const updatedAttributeDefinitions = attributeDefinitions.filter( + (attributeDefinition) => + attributeDefinition.previous && attributeDefinition.next + ) + + const addedAttributeDefinitions = attributeDefinitions.filter( + (attributeDefinition) => + !attributeDefinition.previous && attributeDefinition.next + ) + + return [ + ...removedAttributeDefinitions.map((attributeDef) => ({ + action: 'removeAttributeDefinition', + name: attributeDef.previous.name, + })), + ...updatedAttributeDefinitions + .map((updatedAttributeDefinition) => + generateBaseFieldsUpdateActions( + updatedAttributeDefinition.previous, + updatedAttributeDefinition.next, + { + label: { + action: 'changeLabel', + attributeName: updatedAttributeDefinition.previous.name, + }, + inputTip: { + action: 'setInputTip', + attributeName: updatedAttributeDefinition.previous.name, + }, + inputHint: { + action: 'changeInputHint', + attributeName: updatedAttributeDefinition.previous.name, + }, + isSearchable: { + action: 'changeIsSearchable', + attributeName: updatedAttributeDefinition.previous.name, + }, + attributeConstraint: { + action: 'changeAttributeConstraint', + attributeName: updatedAttributeDefinition.previous.name, + }, + } + ) + ) + .flat(), + ...addedAttributeDefinitions.map((attributeDef) => ({ + action: 'addAttributeDefinition', + attribute: attributeDef.next, + })), + ] +} + +export type AttributeEnumValues = { + previous?: { + key: string + label: LocalizedString | string | undefined + [key: string]: unknown + } + next?: { + key: string + label: LocalizedString | string | undefined + [key: string]: unknown + } + hint?: { attributeName: string; isLocalized: boolean } +} +const generateUpdateActionsForAttributeEnumValues = ( + attributeEnumValues: Array = [] +) => { + const removedAttributeEnumValues = attributeEnumValues.filter( + (attributeEnumValue) => + attributeEnumValue.previous && !attributeEnumValue.next + ) + const updatedAttributeEnumValues = attributeEnumValues.filter( + (attributeEnumValue) => + attributeEnumValue.next && attributeEnumValue.previous + ) + const addedAttributeEnumValues = attributeEnumValues.filter( + (attributeEnumValue) => + !attributeEnumValue.previous && attributeEnumValue.next + ) + + return [ + ...Object.values( + removedAttributeEnumValues.reduce<{ [key: string]: any }>( + (nextEnumUpdateActions, removedAttributeEnumValue) => { + const removedAttributeEnumValueOfSameAttributeName = + nextEnumUpdateActions[ + removedAttributeEnumValue.hint.attributeName + ] || { + keys: [], + attributeName: removedAttributeEnumValue.hint.attributeName, + action: 'removeEnumValues', + } + return { + ...nextEnumUpdateActions, + [removedAttributeEnumValue.hint.attributeName]: { + ...removedAttributeEnumValueOfSameAttributeName, + keys: [ + ...removedAttributeEnumValueOfSameAttributeName.keys, + removedAttributeEnumValue.previous.key, + ], + }, + } + }, + {} + ) + ), + ...updatedAttributeEnumValues + .map((updatedAttributeEnumValue) => { + const updateActions = generateBaseFieldsUpdateActions( + updatedAttributeEnumValue.previous, + updatedAttributeEnumValue.next, + { + key: { + action: 'changeEnumKey', + attributeName: updatedAttributeEnumValue.hint.attributeName, + }, + } + ) + if ( + !deepEqual( + updatedAttributeEnumValue.previous.label, + updatedAttributeEnumValue.next.label + ) + ) { + if (updatedAttributeEnumValue.hint.isLocalized) { + return [ + ...updateActions, + { + action: 'changeLocalizedEnumValueLabel', + attributeName: updatedAttributeEnumValue.hint.attributeName, + newValue: updatedAttributeEnumValue.next, + }, + ] + } + return [ + ...updateActions, + { + action: 'changePlainEnumValueLabel', + attributeName: updatedAttributeEnumValue.hint.attributeName, + newValue: updatedAttributeEnumValue.next, + }, + ] + } + return updateActions + }) + .flat(), + ...addedAttributeEnumValues.map((addedAttributeEnumValue) => ({ + action: addedAttributeEnumValue.hint.isLocalized + ? 'addLocalizedEnumValue' + : 'addPlainEnumValue', + attributeName: addedAttributeEnumValue.hint.attributeName, + value: addedAttributeEnumValue.next, + })), + ] +} + +const generateChangeAttributeOrderAction = ( + attrsOld: Array = [], + attrsNew: Array = [], + updateActions: Array = [] +) => { + if (!attrsOld.length || !attrsNew.length) return null + + const newAttributesOrder = attrsNew.map((attribute) => attribute.name) + + const removedAttributeNames = updateActions + .filter((action) => action.action === 'removeAttributeDefinition') + .map((action) => action.name) + + const addedAttributeNames = updateActions + .filter((action) => action.action === 'addAttributeDefinition') + .map((action) => action.attribute.name) + + // changeAttributeOrder action will be sent to CTP API as the last action so we have to + // calculate how the productType will look like after adding/removing attributes + const patchedOldAttributesOrder = attrsOld + .map((attribute) => attribute.name) + .filter((name) => !removedAttributeNames.includes(name)) + .concat(addedAttributeNames) + + if (newAttributesOrder.join(',') !== patchedOldAttributesOrder.join(',')) + return { + action: 'changeAttributeOrderByName', + attributeNames: newAttributesOrder, + } + + return null +} + +export type NestedValues = { + attributeDefinitions?: Array + attributeEnumValues?: Array +} +export const actionsMapForHints = ( + nestedValuesChanges: NestedValues, + ptOld: any, + ptNew: any +) => { + const updateActions = [ + ...generateUpdateActionsForAttributeDefinitions( + nestedValuesChanges?.attributeDefinitions + ), + ...generateUpdateActionsForAttributeEnumValues( + nestedValuesChanges?.attributeEnumValues + ), + ] + + const changeAttributeOrderAction = generateChangeAttributeOrderAction( + ptOld.attributes, + ptNew.attributes, + updateActions + ) + + if (changeAttributeOrderAction) updateActions.push(changeAttributeOrderAction) + + return updateActions +} diff --git a/packages/sync-actions/src/product-types.ts b/packages/sync-actions/src/product-types.ts new file mode 100644 index 000000000..5fb325d5a --- /dev/null +++ b/packages/sync-actions/src/product-types.ts @@ -0,0 +1,65 @@ +import { + ProductType, + ProductTypeUpdateAction, +} from '@commercetools/platform-sdk' +import * as productTypeActions from './product-types-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import { + ActionGroup, + SyncActionConfig as BaseSyncActionConfig, +} from './types/update-actions' + +type SyncActionConfig = { withHints?: boolean } & BaseSyncActionConfig + +const actionGroups = ['base'] + +const createProductTypeMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj, options) { + return [ + // we support only base fields for the product type, + // for attributes, applying hints would be recommended + mapActionGroup('base', () => + productTypeActions.actionsMapBase( + diff, + oldObj, + newObj, + syncActionConfig + ) + ), + productTypeActions.actionsMapForHints( + options.nestedValuesChanges, + oldObj, + newObj + ), + ].flat() + } +} + +export const createSyncProductTypes = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +) => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createProductTypeMapActions( + mapActionGroup, + syncActionConfig + ) + const onBeforeApplyingDiff: any = null + const buildActions = createBuildActions( + diff, + doMapActions, + onBeforeApplyingDiff, + { withHints: true } + ) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/products.ts b/packages/sync-actions/src/products.ts new file mode 100644 index 000000000..e29b213eb --- /dev/null +++ b/packages/sync-actions/src/products.ts @@ -0,0 +1,173 @@ +import { ProductData, ProductUpdateAction } from '@commercetools/platform-sdk' +import { + actionsMapAddVariants, + actionsMapAssets, + actionsMapAttributes, + actionsMapBase, + actionsMapCategories, + actionsMapCategoryOrderHints, + actionsMapImages, + actionsMapMasterVariant, + actionsMapMeta, + actionsMapPrices, + actionsMapPricesCustom, + actionsMapReferences, + actionsMapRemoveVariants, +} from './product-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import copyEmptyArrayProps from './utils/copy-empty-array-props' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import findMatchingPairs from './utils/find-matching-pairs' + +const actionGroups = [ + 'base', + 'meta', + 'references', + 'prices', + 'pricesCustom', + 'attributes', + 'images', + 'variants', + 'categories', + 'categoryOrderHints', +] + +const createProductMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj, options) { + const allActions: Array> = [] + const { sameForAllAttributeNames, enableDiscounted } = options + const { publish, staged } = newObj + + const variantHashMap = findMatchingPairs( + diff.variants, + oldObj.variants, + newObj.variants + ) + + allActions.push( + mapActionGroup('attributes', () => + actionsMapAttributes( + diff, + oldObj, + newObj, + sameForAllAttributeNames || [], + variantHashMap + ) + ) + ) + + allActions.push( + mapActionGroup('variants', () => + actionsMapAddVariants(diff, oldObj, newObj) + ) + ) + + allActions.push(actionsMapMasterVariant(oldObj, newObj)) + + allActions.push( + mapActionGroup('variants', () => + actionsMapRemoveVariants(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('meta', () => actionsMapMeta(diff, oldObj, newObj)) + ) + + allActions.push( + mapActionGroup('references', () => + actionsMapReferences(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('images', () => + actionsMapImages(diff, oldObj, newObj, variantHashMap) + ) + ) + + allActions.push( + mapActionGroup('pricesCustom', () => + actionsMapPricesCustom(diff, oldObj, newObj, variantHashMap) + ) + ) + + allActions.push( + mapActionGroup('prices', () => + actionsMapPrices(diff, oldObj, newObj, variantHashMap, enableDiscounted) + ) + ) + + allActions.push( + mapActionGroup('categories', () => actionsMapCategories(diff)) + ) + + allActions.push( + mapActionGroup('categories', () => actionsMapCategoryOrderHints(diff)) + ) + + allActions.push( + mapActionGroup('assets', () => + actionsMapAssets(diff, oldObj, newObj, variantHashMap) + ) + ) + + if (publish === true || staged === false) + return allActions.flat().map((action) => ({ ...action, staged: false })) + + return allActions.flat() + } +} + +function moveMasterVariantsIntoVariants(before: any, now: any): Array { + const [beforeCopy, nowCopy] = copyEmptyArrayProps(before, now) + const move = (obj: any): any => ({ + ...obj, + masterVariant: undefined, + variants: [obj.masterVariant, ...(obj.variants || [])], + }) + const hasMasterVariant = (obj: any): any => obj && obj.masterVariant + + return [ + hasMasterVariant(beforeCopy) ? move(beforeCopy) : beforeCopy, + hasMasterVariant(nowCopy) ? move(nowCopy) : nowCopy, + ] +} + +export type ProductSync = { key: string; id: string } & ProductData + +export const createSyncProducts = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createProductMapActions(mapActionGroup, syncActionConfig) + + const buildActions = createBuildActions( + diff, + doMapActions, + moveMasterVariantsIntoVariants + ) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/projects-actions.ts b/packages/sync-actions/src/projects-actions.ts new file mode 100644 index 000000000..b81470bb3 --- /dev/null +++ b/packages/sync-actions/src/projects-actions.ts @@ -0,0 +1,81 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'changeCurrencies', key: 'currencies' }, + { action: 'changeCountries', key: 'countries' }, + { action: 'changeLanguages', key: 'languages' }, + { + action: 'changeMessagesConfiguration', + actionKey: 'messagesConfiguration', + key: 'messages', + }, + { action: 'setShippingRateInputType', key: 'shippingRateInputType' }, +] + +export const myBusinessUnitActionsList: Array = [ + { + action: 'changeMyBusinessUnitStatusOnCreation', + key: 'myBusinessUnitStatusOnCreation', + actionKey: 'status', + }, + { + action: 'setMyBusinessUnitAssociateRoleOnCreation', + key: 'myBusinessUnitAssociateRoleOnCreation', + actionKey: 'associateRole', + }, +] + +export const customerSearchActionsList: Array = [ + { + action: 'changeCustomerSearchStatus', + key: 'status', + }, +] + +export const actionsMapBase: ActionMapBase = ( + diff, + oldObj, + newObj, + config = {} +) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config.shouldOmitEmptyString, + }) +} + +export const actionsMapBusinessUnit: ActionMap = (diff, oldObj, newObj) => { + const { businessUnits } = diff + if (!businessUnits) { + return [] + } + return buildBaseAttributesActions({ + actions: myBusinessUnitActionsList, + diff: businessUnits, + oldObj: oldObj.businessUnits, + newObj: newObj.businessUnits, + }) +} + +export const actionsMapCustomer: ActionMap = (diff, oldObj, newObj) => { + const { searchIndexing } = diff + if (!searchIndexing) { + return [] + } + const { customers } = searchIndexing + if (!customers) { + return [] + } + return buildBaseAttributesActions({ + actions: customerSearchActionsList, + diff: customers, + oldObj: oldObj.searchIndexing.customers, + newObj: newObj.searchIndexing.customers, + }) +} diff --git a/packages/sync-actions/src/projects.ts b/packages/sync-actions/src/projects.ts new file mode 100644 index 000000000..ba4097f30 --- /dev/null +++ b/packages/sync-actions/src/projects.ts @@ -0,0 +1,68 @@ +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { + MessagesConfigurationDraft, + Project, + ProjectUpdateAction, +} from '@commercetools/platform-sdk' +import { + actionsMapBase, + actionsMapBusinessUnit, + actionsMapCustomer, +} from './projects-actions' +import { diff } from './utils/diffpatcher' +import { + ActionGroup, + SyncAction, + SyncActionConfig, + UpdateAction, +} from './types/update-actions' + +export const actionGroups = ['base', 'myBusinessUnit', 'customerSearch'] + +const createChannelsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('myBusinessUnit', () => + actionsMapBusinessUnit(diff, oldObj, newObj) + ) + ) + + allActions.push( + mapActionGroup('customerSearch', () => + actionsMapCustomer(diff, oldObj, newObj) + ) + ) + + return allActions.flat() + } +} + +export const createSyncProjects = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createChannelsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/quote-requests-actions.ts b/packages/sync-actions/src/quote-requests-actions.ts new file mode 100644 index 000000000..300804ad0 --- /dev/null +++ b/packages/sync-actions/src/quote-requests-actions.ts @@ -0,0 +1,18 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeQuoteRequestState', key: 'quoteRequestState' }, + { action: 'transitionState', key: 'state' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/quote-requests.ts b/packages/sync-actions/src/quote-requests.ts new file mode 100644 index 000000000..d2ae861da --- /dev/null +++ b/packages/sync-actions/src/quote-requests.ts @@ -0,0 +1,60 @@ +import { + QuoteRequest, + QuoteRequestUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './quote-requests-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +const actionGroups = ['base', 'custom'] + +const createQuoteRequestsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export const createSyncQuoteRequest = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createQuoteRequestsMapActions( + mapActionGroup, + syncActionConfig + ) + + const buildActions = createBuildActions< + QuoteRequest, + QuoteRequestUpdateAction + >(diff, doMapActions) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/quotes-actions.ts b/packages/sync-actions/src/quotes-actions.ts new file mode 100644 index 000000000..2be2697b3 --- /dev/null +++ b/packages/sync-actions/src/quotes-actions.ts @@ -0,0 +1,19 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeQuoteState', key: 'quoteState' }, + { action: 'requestQuoteRenegotiation', key: 'buyerComment' }, + { action: 'transitionState', key: 'state' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/quotes.ts b/packages/sync-actions/src/quotes.ts new file mode 100644 index 000000000..878830740 --- /dev/null +++ b/packages/sync-actions/src/quotes.ts @@ -0,0 +1,54 @@ +import { Quote, QuoteUpdateAction } from '@commercetools/platform-sdk' +import { actionsMapBase } from './quotes-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +const actionGroups = ['base', 'custom'] + +const createQuotesMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export const createSyncQuote = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createQuotesMapActions(mapActionGroup, syncActionConfig) + + const buildActions = createBuildActions( + diff, + doMapActions + ) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/shipping-methods-actions.ts b/packages/sync-actions/src/shipping-methods-actions.ts new file mode 100644 index 000000000..c4982b665 --- /dev/null +++ b/packages/sync-actions/src/shipping-methods-actions.ts @@ -0,0 +1,126 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { ZoneRate } from '@commercetools/platform-sdk' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'setKey', key: 'key' }, + { action: 'changeName', key: 'name' }, + { action: 'setLocalizedName', key: 'localizedName' }, + { action: 'setDescription', key: 'description' }, + { action: 'setLocalizedDescription', key: 'localizedDescription' }, + { action: 'changeIsDefault', key: 'isDefault' }, + { action: 'setPredicate', key: 'predicate' }, + { action: 'changeTaxCategory', key: 'taxCategory' }, + { action: 'changeActive', key: 'active' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +const addShippingRates = (newZoneRate: ZoneRate) => + newZoneRate.shippingRates + ? newZoneRate.shippingRates.map((shippingRate) => ({ + action: 'addShippingRate', + zone: newZoneRate.zone, + shippingRate, + })) + : [] + +const actionsMapZoneRatesShippingRates: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('shippingRates', { + [ADD_ACTIONS]: (newShippingRate) => ({ + action: 'addShippingRate', + zone: newObj.zone, + shippingRate: newShippingRate, + }), + [REMOVE_ACTIONS]: (oldShippingRate) => ({ + action: 'removeShippingRate', + zone: oldObj.zone, + shippingRate: oldShippingRate, + }), + [CHANGE_ACTIONS]: (oldShippingRate, newShippingRate) => [ + { + action: 'removeShippingRate', + zone: oldObj.zone, + shippingRate: oldShippingRate, + }, + { + action: 'addShippingRate', + zone: newObj.zone, + shippingRate: newShippingRate, + }, + ], + }) + + return handler(diff, oldObj, newObj) +} + +export const actionsMapZoneRates: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('zoneRates', { + [ADD_ACTIONS]: (newZoneRate) => [ + { + action: 'addZone', + zone: newZoneRate.zone, + }, + ...addShippingRates(newZoneRate), + ], + [REMOVE_ACTIONS]: (oldZoneRate) => ({ + action: 'removeZone', + zone: oldZoneRate.zone, + }), + [CHANGE_ACTIONS]: (oldZoneRate, newZoneRate) => { + let hasZoneActions = false + + const shippingRateActions = Object.keys(diff.zoneRates).reduce( + (actions, key) => { + if (diff.zoneRates[key].zone) hasZoneActions = true + + if (diff.zoneRates[key].shippingRates) + return [ + ...actions, + ...actionsMapZoneRatesShippingRates( + diff.zoneRates[key], + oldZoneRate, + newZoneRate + ), + ] + return actions + }, + [] + ) + + return ( + hasZoneActions + ? [ + ...shippingRateActions, + ...[ + { + action: 'removeZone', + zone: oldZoneRate.zone, + }, + { + action: 'addZone', + zone: newZoneRate.zone, + }, + ], + ] + : shippingRateActions + ).flat() + }, + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/shipping-methods.ts b/packages/sync-actions/src/shipping-methods.ts new file mode 100644 index 000000000..3c2bbbfcd --- /dev/null +++ b/packages/sync-actions/src/shipping-methods.ts @@ -0,0 +1,69 @@ +import { + ShippingMethod, + ShippingMethodUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase, actionsMapZoneRates } from './shipping-methods-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'zoneRates', 'custom'] + +const createShippingMethodsMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + allActions.push( + mapActionGroup('zoneRates', () => + actionsMapZoneRates(diff, oldObj, newObj) + ).flat() + ) + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + return allActions.flat() + } +} + +export const createSyncShippingMethods = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // actionGroupList contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createShippingMethodsMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions< + ShippingMethod, + ShippingMethodUpdateAction + >(diff, doMapActions) + return { buildActions } +} diff --git a/packages/sync-actions/src/staged-quotes-actions.ts b/packages/sync-actions/src/staged-quotes-actions.ts new file mode 100644 index 000000000..578c95f57 --- /dev/null +++ b/packages/sync-actions/src/staged-quotes-actions.ts @@ -0,0 +1,20 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeStagedQuoteState', key: 'stagedQuoteState' }, + { action: 'setSellerComment', key: 'sellerComment' }, + { action: 'setValidTo', key: 'validTo' }, + { action: 'transitionState', key: 'state' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} diff --git a/packages/sync-actions/src/staged-quotes.ts b/packages/sync-actions/src/staged-quotes.ts new file mode 100644 index 000000000..ea17266f6 --- /dev/null +++ b/packages/sync-actions/src/staged-quotes.ts @@ -0,0 +1,66 @@ +import { + CustomFields, + StagedQuote, + StagedQuoteUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase } from './staged-quotes-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +const actionGroups = ['base', 'custom'] + +const createStagedQuotesMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + + allActions.push( + mapActionGroup('custom', () => actionsMapCustom(diff, newObj, oldObj)) + ) + + return allActions.flat() + } +} + +export type StagedQuoteSync = { + stagedQuoteState: string + custom: CustomFields +} & StagedQuote + +export const createSyncStagedQuote = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createStagedQuotesMapActions( + mapActionGroup, + syncActionConfig + ) + + const buildActions = createBuildActions< + StagedQuoteSync, + StagedQuoteUpdateAction + >(diff, doMapActions) + + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/state-actions.ts b/packages/sync-actions/src/state-actions.ts new file mode 100644 index 000000000..20d270681 --- /dev/null +++ b/packages/sync-actions/src/state-actions.ts @@ -0,0 +1,41 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeKey', key: 'key' }, + { action: 'setName', key: 'name' }, + { action: 'setDescription', key: 'description' }, + { action: 'changeType', key: 'type' }, + { action: 'changeInitial', key: 'initial' }, + { action: 'setTransitions', key: 'transitions' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapRoles: ActionMap = (diff, oldObj, newObj) => { + const buildArrayActions = createBuildArrayActions('roles', { + [ADD_ACTIONS]: (newRole) => ({ + action: 'addRoles', + roles: newRole, + }), + [REMOVE_ACTIONS]: (oldRole) => ({ + action: 'removeRoles', + roles: oldRole, + }), + }) + + return buildArrayActions(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/states.ts b/packages/sync-actions/src/states.ts new file mode 100644 index 000000000..76c76a1b3 --- /dev/null +++ b/packages/sync-actions/src/states.ts @@ -0,0 +1,63 @@ +import { State, StateUpdateAction } from '@commercetools/platform-sdk' +import { actionsMapBase, actionsMapRoles } from './state-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base'] + +// This function groups `addRoles` and `removeRoles` actions to one array +function groupRoleActions([actions]: Array< + Array +>): Array { + const addActionRoles: Array = [] + const removeActionRoles: Array = [] + actions.forEach((action: UpdateAction) => { + if (action.action === 'removeRoles') removeActionRoles.push(action.roles) + if (action.action === 'addRoles') addActionRoles.push(action.roles) + }) + return [ + { action: 'removeRoles', roles: removeActionRoles }, + { action: 'addRoles', roles: addActionRoles }, + ].filter((action: UpdateAction): number => action.roles.length) +} + +const createStatesMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const baseActions: Array> = [] + const roleActions: Array> = [] + baseActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + roleActions.push( + mapActionGroup('roles', () => actionsMapRoles(diff, oldObj, newObj)) + ) + return [...baseActions, ...groupRoleActions(roleActions)].flat() + } +} + +export const createSyncStates = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createStatesMapActions(mapActionGroup, syncActionConfig) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/stores-actions.ts b/packages/sync-actions/src/stores-actions.ts new file mode 100644 index 000000000..5aa8973c2 --- /dev/null +++ b/packages/sync-actions/src/stores-actions.ts @@ -0,0 +1,19 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'setName', key: 'name' }, + { action: 'setLanguages', key: 'languages' }, + { action: 'setDistributionChannels', key: 'distributionChannels' }, + { action: 'setSupplyChannels', key: 'supplyChannels' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + }) +} diff --git a/packages/sync-actions/src/stores.ts b/packages/sync-actions/src/stores.ts new file mode 100644 index 000000000..a02a52dd0 --- /dev/null +++ b/packages/sync-actions/src/stores.ts @@ -0,0 +1,58 @@ +import { Store, StoreUpdateAction } from '@commercetools/platform-sdk' +import { actionsMapBase } from './stores-actions' +import { ActionGroup, SyncAction, UpdateAction } from './types/update-actions' +import actionsMapCustom from './utils/action-map-custom' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup from './utils/create-map-action-group' +import { Delta, diff } from './utils/diffpatcher' + +export const actionGroups = ['base'] + +function createStoresMapActions( + mapActionGroup: ( + type: string, + fn: () => Array + ) => Array +): ( + diff: Delta, + next: any, + previous: any, + options: any +) => Array { + return function doMapActions( + diff, + next: any, + previous: any + ): Array { + const allActions = [] + allActions.push( + mapActionGroup( + 'base', + (): Array => actionsMapBase(diff, previous, next) + ) + ) + allActions.push( + mapActionGroup( + 'custom', + (): Array => actionsMapCustom(diff, next, previous) + ) + ) + + return allActions.flat() + } +} + +export const createSyncStores = ( + actionGroupList?: Array +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createStoresMapActions(mapActionGroup) + const onBeforeApplyingDiff: any = null + const buildActions = createBuildActions( + diff, + doMapActions, + onBeforeApplyingDiff + ) + + return { buildActions } +} diff --git a/packages/sync-actions/src/tax-categories-actions.ts b/packages/sync-actions/src/tax-categories-actions.ts new file mode 100644 index 000000000..658895e7c --- /dev/null +++ b/packages/sync-actions/src/tax-categories-actions.ts @@ -0,0 +1,45 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'setDescription', key: 'description' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapRates: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('rates', { + [ADD_ACTIONS]: (newObject) => ({ + action: 'addTaxRate', + taxRate: newObject, + }), + [REMOVE_ACTIONS]: (objectToRemove) => ({ + action: 'removeTaxRate', + taxRateId: objectToRemove.id, + }), + [CHANGE_ACTIONS]: (oldObject, updatedObject) => ({ + action: 'replaceTaxRate', + taxRateId: + oldObject.id === updatedObject.id ? oldObject.id : updatedObject.id, + taxRate: updatedObject, + }), + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/tax-categories.ts b/packages/sync-actions/src/tax-categories.ts new file mode 100644 index 000000000..b8428ae78 --- /dev/null +++ b/packages/sync-actions/src/tax-categories.ts @@ -0,0 +1,63 @@ +import { + TaxCategory, + TaxCategoryUpdateAction, +} from '@commercetools/platform-sdk' +import { actionsMapBase, actionsMapRates } from './tax-categories-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' + +export const actionGroups = ['base', 'rates'] + +const createTaxCategoriesMapActions: MapAction = ( + mapActionGroup, + syncActionConfig +) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + allActions.push( + mapActionGroup('rates', () => actionsMapRates(diff, oldObj, newObj)) + ) + return allActions.flat() + } +} + +export const createSyncTaxCategories = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // config contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createTaxCategoriesMapActions( + mapActionGroup, + syncActionConfig + ) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/src/types-actions.ts b/packages/sync-actions/src/types-actions.ts new file mode 100644 index 000000000..693599cd5 --- /dev/null +++ b/packages/sync-actions/src/types-actions.ts @@ -0,0 +1,215 @@ +import { deepEqual } from 'fast-equals' +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, +} from './utils/create-build-array-actions' +import { Delta, getDeltaValue } from './utils/diffpatcher' +import extractMatchingPairs from './utils/extract-matching-pairs' +import { ActionMapBase } from './utils/create-map-action-group' +import { UpdateAction } from './types/update-actions' + +const REGEX_NUMBER = new RegExp(/^\d+$/) +const REGEX_UNDERSCORE_NUMBER = new RegExp(/^_\d+$/) +const getIsChangedOperation = (key: string) => REGEX_NUMBER.test(key) +const getIsRemovedOperation = (key: string) => REGEX_UNDERSCORE_NUMBER.test(key) + +export const baseActionsList: Array = [ + { action: 'changeKey', key: 'key' }, + { action: 'changeName', key: 'name' }, + { action: 'setDescription', key: 'description' }, +] + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +function actionsMapEnums( + fieldName: any, + attributeType: any, + attributeDiff: Delta, + previous: any, + next: any +) { + const addEnumActionName = + attributeType === 'Enum' ? 'addEnumValue' : 'addLocalizedEnumValue' + const changeEnumValueLabelActionName = + attributeType === 'Enum' + ? 'changeEnumValueLabel' + : 'changeLocalizedEnumValueLabel' + const changeEnumOrderActionName = + attributeType === 'Enum' + ? 'changeEnumValueOrder' + : 'changeLocalizedEnumValueOrder' + const buildArrayActions = createBuildArrayActions('values', { + [ADD_ACTIONS]: (newEnum) => ({ + fieldName, + action: addEnumActionName, + value: newEnum, + }), + [CHANGE_ACTIONS]: (oldEnum, newEnum) => { + const oldEnumInNext = next.values.find( + (nextEnum: any) => nextEnum.key === oldEnum.key + ) + + // These `changeActions` would impose a nested structure among + // the accumulated `updateActions` generated by `buildArrayActions` + // In the end; we have to flatten the structure before we pass it back + // to the client. + const changeActions = [] + if (oldEnumInNext) { + // If the enum value is changed, we need to change the order first + const isKeyChanged = oldEnum.key !== newEnum.key + + // check if the label is changed + const foundPreviousEnum = previous.values.find( + (previousEnum: any) => previousEnum.key === newEnum.key + ) + const isLabelEqual = deepEqual(foundPreviousEnum.label, newEnum.label) + + if (isKeyChanged) { + // these actions is then flatten in the end + changeActions.push({ + fieldName, + action: changeEnumOrderActionName, + value: newEnum, + }) + } + + if (!isLabelEqual) { + changeActions.push({ + fieldName, + action: changeEnumValueLabelActionName, + value: newEnum, + }) + } + } else { + changeActions.push({ + fieldName, + action: addEnumActionName, + value: newEnum, + }) + } + return changeActions + }, + }) + + const actions: Array = [] + // following lists are necessary to ensure that when we change the + // order of enumValues, we generate one updateAction instead of one at a time. + let newEnumValuesOrder: Array = [] + + buildArrayActions(attributeDiff, previous, next) + .flat() + .forEach((updateAction) => { + if (updateAction.action === changeEnumOrderActionName) { + newEnumValuesOrder = next.values.map((enumValue: any) => enumValue.key) + } else actions.push(updateAction) + }) + + return [ + ...actions, + ...(newEnumValuesOrder.length > 0 + ? [ + { + fieldName, + action: changeEnumOrderActionName, + keys: newEnumValuesOrder, + }, + ] + : []), + ] +} + +export function actionsMapFieldDefinitions( + fieldDefinitionsDiff: { [key: string]: any }, + previous: any, + next: any, + diffPaths: any +) { + const actions: Array = [] + fieldDefinitionsDiff && + Object.entries(fieldDefinitionsDiff).forEach(([diffKey, diffValue]) => { + const extractedPairs = extractMatchingPairs( + diffPaths, + diffKey, + previous, + next + ) + + if (getIsChangedOperation(diffKey)) { + if (Array.isArray(diffValue)) { + const deltaValue = getDeltaValue(diffValue) + if (deltaValue.name) { + actions.push({ + action: 'addFieldDefinition', + fieldDefinition: deltaValue, + }) + } + } else if (diffValue.label) { + actions.push({ + action: 'changeLabel', + label: extractedPairs.newObj.label, + fieldName: extractedPairs.oldObj.name, + }) + } else if (diffValue.inputHint) { + actions.push({ + action: 'changeInputHint', + inputHint: extractedPairs.newObj.inputHint, + fieldName: extractedPairs.oldObj.name, + }) + } else if (diffValue?.type?.values) { + actions.push( + ...actionsMapEnums( + extractedPairs.oldObj.name, + extractedPairs.oldObj.type.name, + diffValue.type, + extractedPairs.oldObj.type, + extractedPairs.newObj.type + ) + ) + } + } else if (getIsRemovedOperation(diffKey)) { + if (Array.isArray(diffValue)) { + if (diffValue.length === 3 && diffValue[2] === 3) { + actions.push({ + action: 'changeFieldDefinitionOrder', + fieldNames: next.map((n: any) => n.name), + }) + } else { + const deltaValue = getDeltaValue(diffValue) + if (deltaValue === undefined && diffValue[0].name) + actions.push({ + action: 'removeFieldDefinition', + fieldName: diffValue[0].name, + }) + } + } + } + }) + + // Make sure to execute removeActions before creating new ones + // in order to prevent any eventual removal of `addAction`. + // List of `removeActions` can be found here + // https://docs.commercetools.com/http-api-projects-types.html#change-key + + return actions.sort((a, b) => { + if ( + a.action === 'removeFieldDefinition' && + a.action === 'removeFieldDefinition' + ) { + return (a.fieldName as string).localeCompare(b.fieldName) + } else if (a.action === 'removeFieldDefinition') { + return -1 + } else if (b.action === 'removeFieldDefinition') { + return -1 + } + return 0 + }) +} diff --git a/packages/sync-actions/src/types.ts b/packages/sync-actions/src/types.ts new file mode 100644 index 000000000..2a72e8866 --- /dev/null +++ b/packages/sync-actions/src/types.ts @@ -0,0 +1,56 @@ +import { Type, TypeUpdateAction } from '@commercetools/platform-sdk/src' +import { actionsMapBase, actionsMapFieldDefinitions } from './types-actions' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import findMatchingPairs from './utils/find-matching-pairs' + +const actionGroups = ['base', 'fieldDefinitions'] + +const createTypeMapActions: MapAction = (mapActionGroup, syncActionConfig) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ), + mapActionGroup('fieldDefinitions', () => + actionsMapFieldDefinitions( + diff.fieldDefinitions, + oldObj.fieldDefinitions, + newObj.fieldDefinitions, + findMatchingPairs( + diff.fieldDefinitions, + oldObj.fieldDefinitions, + newObj.fieldDefinitions, + 'name' + ) + ) + ) + ) + return allActions.flat() + } +} + +export const createSyncTypes = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createTypeMapActions(mapActionGroup, syncActionConfig) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} + +export { actionGroups } diff --git a/packages/sync-actions/src/types/update-actions.ts b/packages/sync-actions/src/types/update-actions.ts new file mode 100644 index 000000000..97bdfda45 --- /dev/null +++ b/packages/sync-actions/src/types/update-actions.ts @@ -0,0 +1,31 @@ +export type UpdateAction = { + action: string + [key: string]: any + actionKey?: string +} + +export type SyncAction< + R extends object | undefined, + S extends UpdateAction, + T = {}, +> = { + buildActions: ( + now: DeepPartial, + before: DeepPartial, + config?: T + ) => Array +} + +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial + } + : T +export type SyncActionConfig = { + shouldOmitEmptyString: boolean +} + +export type ActionGroup = { + type: string + group: 'ignore' | 'allow' +} diff --git a/packages/sync-actions/src/utils/action-map-custom.ts b/packages/sync-actions/src/utils/action-map-custom.ts new file mode 100644 index 000000000..7f08e9dc1 --- /dev/null +++ b/packages/sync-actions/src/utils/action-map-custom.ts @@ -0,0 +1,80 @@ +import { Delta, getDeltaValue } from './diffpatcher' + +import { UpdateAction } from '../types/update-actions' + +const Actions = { + setCustomType: 'setCustomType', + setCustomField: 'setCustomField', +} + +const hasSingleCustomFieldChanged = (diff: Delta) => Array.isArray(diff.custom) +const haveMultipleCustomFieldsChanged = (diff: Delta) => + Boolean(diff.custom.fields) +const hasCustomTypeChanged = (diff: Delta) => Boolean(diff.custom.type) +const extractCustomType = (diff: Delta, previousObject: any) => + Array.isArray(diff.custom.type) + ? getDeltaValue(diff.custom.type, previousObject) + : diff.custom.type +const extractTypeId = (type: any, nextObject: any) => + Array.isArray(type.id) ? getDeltaValue(type.id) : nextObject.custom.type.id +const extractTypeKey = (type: any, nextObject: any) => + Array.isArray(type.key) ? getDeltaValue(type.key) : nextObject.custom.type.key +const extractTypeFields = (diffedFields: any, nextFields: any) => + Array.isArray(diffedFields) ? getDeltaValue(diffedFields) : nextFields +const extractFieldValue = (newFields: any, fieldName: string) => + newFields[fieldName] + +export default function actionsMapCustom( + diff: Delta, + newObj: any, + oldObj: any, + customProps: { actions: any; priceId?: string } = { + actions: {}, + } +) { + const actions: Array = [] + const { actions: customPropsActions, ...options } = customProps + const actionGroup = { ...Actions, ...customPropsActions } + + if (!diff.custom) return actions + if (hasSingleCustomFieldChanged(diff)) { + // If custom is not defined on the new or old category + const custom = getDeltaValue(diff.custom, oldObj) + actions.push({ action: actionGroup.setCustomType, ...options, ...custom }) + } else if (hasCustomTypeChanged(diff)) { + // If custom is set to an empty object on the new or old category + const type = extractCustomType(diff, oldObj) + + if (!type) actions.push({ action: actionGroup.setCustomType, ...options }) + else if (type.id) + actions.push({ + action: actionGroup.setCustomType, + ...options, + type: { + typeId: 'type', + id: extractTypeId(type, newObj), + }, + fields: extractTypeFields(diff.custom.fields, newObj.custom.fields), + }) + else if (type.key) + actions.push({ + action: actionGroup.setCustomType, + ...options, + type: { + typeId: 'type', + key: extractTypeKey(type, newObj), + }, + fields: extractTypeFields(diff.custom.fields, newObj.custom.fields), + }) + } else if (haveMultipleCustomFieldsChanged(diff)) { + const customFieldsActions = Object.keys(diff.custom.fields).map((name) => ({ + action: actionGroup.setCustomField, + ...options, + name, + value: extractFieldValue(newObj.custom.fields, name), + })) + actions.push(...customFieldsActions) + } + + return actions +} diff --git a/packages/sync-actions/src/utils/clone.ts b/packages/sync-actions/src/utils/clone.ts new file mode 100644 index 000000000..315a6ba74 --- /dev/null +++ b/packages/sync-actions/src/utils/clone.ts @@ -0,0 +1,9 @@ +export default function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)) +} + +export function notEmpty( + value: TValue | null | undefined +): value is TValue { + return value !== null && value !== undefined +} diff --git a/packages/sync-actions/src/utils/combine-validity-actions.ts b/packages/sync-actions/src/utils/combine-validity-actions.ts new file mode 100644 index 000000000..5ee088693 --- /dev/null +++ b/packages/sync-actions/src/utils/combine-validity-actions.ts @@ -0,0 +1,25 @@ +import { UpdateAction } from '../types/update-actions' + +const validityActions = ['setValidFrom', 'setValidUntil'] + +const isValidityActions = (actionName: string) => + validityActions.includes(actionName) + +export default function combineValidityActions( + actions: Array = [] +) { + const [setValidFromAction, setValidUntilAction] = actions.filter((item) => + isValidityActions(item.action) + ) + if (setValidFromAction && setValidUntilAction) { + return [ + ...actions.filter((item) => !isValidityActions(item.action)), + { + action: 'setValidFromAndUntil', + validFrom: setValidFromAction.validFrom, + validUntil: setValidUntilAction.validUntil, + }, + ] + } + return actions +} diff --git a/packages/sync-actions/src/utils/common-actions.ts b/packages/sync-actions/src/utils/common-actions.ts new file mode 100644 index 000000000..1ced8e0c7 --- /dev/null +++ b/packages/sync-actions/src/utils/common-actions.ts @@ -0,0 +1,135 @@ +import clone, { notEmpty } from './clone' +import { Delta, getDeltaValue, patch } from './diffpatcher' +import { UpdateAction } from '../types/update-actions' + +const normalizeValue = (value: any) => + typeof value === 'string' ? value.trim() : value + +export const createIsEmptyValue = (emptyValues: Array) => (value: any) => + emptyValues.some((emptyValue) => emptyValue === normalizeValue(value)) + +/** + * Builds actions for simple object properties, given a list of actions + * E.g. [{ action: `changeName`, key: 'name' }] + * + * @param {Array} actions - a list of actions to be built + * based on the given property + * @param {Object} diff - the diff object + * @param {Object} oldObj - the object that needs to be updated + * @param {Object} newObj - the new representation of the object + * @param {Boolean} shouldOmitEmptyString - a flag to determine if we should treat an empty string a NON-value + */ +export function buildBaseAttributesActions({ + actions, + diff, + oldObj, + newObj, + shouldOmitEmptyString, +}: { + actions: Array + diff: Delta + oldObj: any + newObj: any + shouldOmitEmptyString?: boolean +}): Array { + const isEmptyValue = createIsEmptyValue( + shouldOmitEmptyString ? [undefined, null, ''] : [undefined, null] + ) + return actions + .map((item) => { + const key = item.key // e.g.: name, description, ... + const actionKey = item.actionKey || item.key + const delta = diff[key] + const before = oldObj[key] + const now = newObj[key] + const isNotDefinedBefore = isEmptyValue(oldObj[key]) + const isNotDefinedNow = isEmptyValue(newObj[key]) + if (!delta) return undefined + + if (isNotDefinedNow && isNotDefinedBefore) return undefined + + if (!isNotDefinedNow && isNotDefinedBefore) + // no value previously set + return { action: item.action, [actionKey]: now } + + /* no new value */ + if (isNotDefinedNow && !{}.hasOwnProperty.call(newObj, key)) + return undefined + + if (isNotDefinedNow && {}.hasOwnProperty.call(newObj, key)) + // value unset + return { action: item.action } + + // We need to clone `before` as `patch` will mutate it + const patched = patch(clone(before), delta) + return { action: item.action, [actionKey]: patched } + }) + .filter(notEmpty) +} + +/** + * Builds actions for simple reference objects, given a list of actions + * E.g. [{ action: `setTaxCategory`, key: 'taxCategory' }] + * + * @param {Array} actions - a list of actions to be built + * based on the given property + * @param {Object} diff - the diff object + * @param {Object} oldObj - the object that needs to be updated + * @param {Object} newObj - the new representation of the object + */ +export function buildReferenceActions({ + actions, + diff, + oldObj, + newObj, +}: { + actions: Array + diff: Delta + oldObj: any + newObj: any +}): Array<{ action: string }> { + return actions + .map((item) => { + const action = item.action + const key = item.key + + if ( + diff[key] && + // The `key` value was added or removed + (Array.isArray(diff[key]) || + // The `key` value id changed + diff[key].id) + ) { + const newValue = Array.isArray(diff[key]) + ? getDeltaValue(diff[key]) + : newObj[key] + + if (!newValue) return { action } + + // When the `id` of the object is undefined + if (!newValue.id) { + return { + action, + [key]: { + typeId: newValue.typeId, + key: newValue.key, + }, + } + } + + return { + action, + // We only need to pass a reference to the object. + // This prevents accidentally sending the expanded (`obj`) + // over the wire. + [key]: { + typeId: newValue.typeId, + id: newValue.id, + }, + } + } + + return undefined + }) + .filter((action) => action) +} diff --git a/packages/sync-actions/src/utils/copy-empty-array-props.ts b/packages/sync-actions/src/utils/copy-empty-array-props.ts new file mode 100644 index 000000000..299e2981a --- /dev/null +++ b/packages/sync-actions/src/utils/copy-empty-array-props.ts @@ -0,0 +1,75 @@ +const CUSTOM = 'custom' + +/** + * @function copyEmptyArrayProps + * @description Create new key with empty array value on `newobj` for the arrays exist on `oldObj` and doesnt exist on `newobj` + * One use case is to easily compare two object without generating this error `Cannot read property '0' of undefined` + * @param {Object} oldObj + * @param {Object} newObj + * @returns {Array} Ordered Array [oldObj, newObj] + */ +export default function copyEmptyArrayProps( + oldObj: any = {}, + newObj: any = {} +): Array { + if (oldObj && newObj) { + const nextObjectWithEmptyArray = Object.entries(oldObj).reduce( + (merged, [key, value]) => { + // Ignore CUSTOM key as this object is dynamic and its up to the user to dynamically change it + // todo, it would be better if we pass it as ignored keys param + if (key === CUSTOM) return merged + + if (Array.isArray(value) && newObj[key] && newObj[key].length >= 1) { + /* eslint-disable no-plusplus */ + const hashMapValue = value.reduce((acc, val) => { + acc[val.id] = val + return acc + }, {}) + for (let i = 0; i < newObj[key].length; i++) { + if ( + newObj[key][i] && + typeof newObj[key][i] === 'object' && + newObj[key][i].id + ) { + // Since its unordered array elements then check if the element on `oldObj` exists by id + const foundObject = hashMapValue[newObj[key][i].id] + if (foundObject) { + const [, nestedObject] = copyEmptyArrayProps( + foundObject, + newObj[key][i] + ) + if (Object.isFrozen(merged[key])) { + /* eslint-disable no-param-reassign */ + merged[key] = merged[key].slice() + } + /* eslint-disable no-param-reassign */ + merged[key][i] = nestedObject + } + } + } + + return merged + } + if (Array.isArray(value)) { + merged[key] = newObj[key] ? newObj[key] : [] + return merged + } + if ( + newObj[key] && + typeof value === 'object' && + // Ignore Date as this will create invalid object since typeof date === 'object' return true + // ex: {date: new Date()} will result {date: {}} + !(value instanceof Date) + ) { + const [, nestedObject] = copyEmptyArrayProps(value, newObj[key]) + merged[key] = nestedObject + return merged + } + return merged + }, + { ...newObj } + ) + return [oldObj, nextObjectWithEmptyArray] + } + return [oldObj, newObj] +} diff --git a/packages/sync-actions/src/utils/create-build-actions.ts b/packages/sync-actions/src/utils/create-build-actions.ts new file mode 100644 index 000000000..c7faaf6d2 --- /dev/null +++ b/packages/sync-actions/src/utils/create-build-actions.ts @@ -0,0 +1,114 @@ +import { deepEqual } from 'fast-equals' +import { DeepPartial, UpdateAction } from '../types/update-actions' +import { Price, ProductVariant } from '@commercetools/platform-sdk' + +function applyOnBeforeDiff( + before: any, + now: any, + fn?: (before: any, now: any) => Array +) { + return fn && typeof fn === 'function' ? fn(before, now) : [before, now] +} + +const createPriceComparator = (price: Price) => ({ + value: { currencyCode: price.value.currencyCode }, + channel: price.channel, + country: price.country, + customerGroup: price.customerGroup, + validFrom: price.validFrom, + validUntil: price.validUntil, +}) + +function arePricesStructurallyEqual(oldPrice: Price, newPrice: Price) { + const oldPriceComparison = createPriceComparator(oldPrice) + const newPriceComparison = createPriceComparator(newPrice) + return deepEqual(newPriceComparison, oldPriceComparison) +} + +function extractPriceFromPreviousVariant( + newPrice: Price, + previousVariant?: ProductVariant +) { + if (!previousVariant) return null + const price = previousVariant.prices.find((oldPrice) => + arePricesStructurallyEqual(oldPrice, newPrice) + ) + return price || null +} + +function injectMissingPriceIds( + nextVariants: Array, + previousVariants: Array +) { + return nextVariants.map((newVariant) => { + const { prices, ...restOfVariant } = newVariant + + if (!prices) return restOfVariant + const oldVariant = previousVariants.find( + (previousVariant) => + (previousVariant.id && previousVariant.id === newVariant.id) || + (previousVariant.key && previousVariant.key === newVariant.key) || + (previousVariant.sku && previousVariant.sku === newVariant.sku) + ) + + return { + ...restOfVariant, + prices: prices.map((price) => { + let newPrice: any = { ...price } + const oldPrice = extractPriceFromPreviousVariant(price, oldVariant) + + if (oldPrice) { + // copy ID if not provided + if (!newPrice.id) { + newPrice.id = oldPrice.id + } + + if (!newPrice.value.type) { + newPrice.value.type = oldPrice.value.type + } + + if (!newPrice.value.fractionDigits) { + newPrice.value.fractionDigits = oldPrice.value.fractionDigits + } + } + + return newPrice + }), + } + }) +} + +export default function createBuildActions( + differ: (oldObj: any, newObj: any) => any | undefined, + doMapActions: any, + onBeforeDiff?: (before: DeepPartial, now: DeepPartial) => Array, + buildActionsConfig: any = {} +) { + return function buildActions( + now: DeepPartial, + before: DeepPartial, + options = {} + ): Array { + if (!now || !before) + throw new Error( + 'Missing either `newObj` or `oldObj` ' + + 'in order to build update actions' + ) + + const [processedBefore, processedNow] = applyOnBeforeDiff( + before, + now, + onBeforeDiff + ) + + if (processedNow.variants && processedBefore.variants) + processedNow.variants = injectMissingPriceIds( + processedNow.variants, + processedBefore.variants + ) + + const diffed = differ(processedBefore, processedNow) + if (!buildActionsConfig.withHints && !diffed) return [] + return doMapActions(diffed, processedNow, processedBefore, options) + } +} diff --git a/packages/sync-actions/src/utils/create-build-array-actions.ts b/packages/sync-actions/src/utils/create-build-array-actions.ts new file mode 100644 index 000000000..351362dcf --- /dev/null +++ b/packages/sync-actions/src/utils/create-build-array-actions.ts @@ -0,0 +1,164 @@ +import { Delta } from './diffpatcher' + +import { UpdateAction } from '../types/update-actions' + +const REGEX_NUMBER = new RegExp(/^\d+$/) +const REGEX_UNDERSCORE_NUMBER = new RegExp(/^_\d+$/) + +export const ADD_ACTIONS = 'create' +export const REMOVE_ACTIONS = 'remove' +export const CHANGE_ACTIONS = 'change' + +/** + * Tests a delta to see if it represents a create action. + * eg. delta: + * { + * 0: [ { foo: 'bar' } ] + * } + * + * @param {object} obj The delta generated by the diffpatcher + * @param {string} key key of generated delta to examine + * @return {Boolean} Returns true if delta represents a create action, + * false otherwise + */ +function isCreateAction(obj: { [key: string]: any }, key: string): boolean { + return ( + REGEX_NUMBER.test(key) && Array.isArray(obj[key]) && obj[key].length === 1 + ) +} + +/** + * Tests a delta to see if it represents a change action. + * eg. delta: + * + * { + * 0: { + * foo: ['bar', 'baz'] + * } + * } + * @param {object} obj The delta generated by the diffpatcher + * @param {string} key key of generated delta to examine + * @return {Boolean} Returns true if delta represents a change action, + * false otherwise + */ +function isChangeAction(obj: any, key: string): boolean { + return ( + REGEX_NUMBER.test(key) && + (typeof obj[key] === 'object' || typeof obj[key] === 'string') + ) +} + +/** + * Tests a delta to see if it represents a remove action. + * eg. delta: + * + * { + * _0: [ 'foo', 0, 0 ] + * } + * @param {object} obj The delta generated by the diffpatcher + * @param {string} key key of generated delta to examine + * @return {Boolean} Returns true if delta represents a remove action, + * false otherwise + */ +function isRemoveAction(obj: any, key: string): boolean { + return ( + REGEX_UNDERSCORE_NUMBER.test(key) && + Array.isArray(obj[key]) && + obj[key].length === 3 && + (typeof obj[key][0] === 'object' || typeof obj[key][0] === 'string') && + obj[key][1] === 0 && + obj[key][2] === 0 + ) +} + +/** + * Generate + configure a function to build actions for nested objects + * @param {string} key key of the attribute containing the array of + * nested objects + * @param {object} config configuration object that can contain the keys + * [ADD_ACTIONS, REMOVE_ACTIONS, CHANGE_ACTIONS], each of + * which is a function. The function should accept the old + new arrays and + * return an action object. + */ +export default function createBuildArrayActions( + key: string, + config: { + [ADD_ACTIONS]?: ( + newItem: any, + key?: number + ) => UpdateAction | Array + [REMOVE_ACTIONS]?: (oldItem: any, key?: number) => UpdateAction + [CHANGE_ACTIONS]?: ( + oldAsset: any, + newAsset: any, + key?: number + ) => UpdateAction | Array + } +) { + return function buildArrayActions( + diff: Delta, + oldObj: any, + newObj: any + ): Array { + let addActions: Array = [] + const removeActions: Array = [] + let changeActions: Array = [] + + if (diff[key]) { + const arrayDelta = diff[key] + + Object.keys(arrayDelta).forEach((index) => { + if (config[ADD_ACTIONS] && isCreateAction(arrayDelta, index)) { + const actionGenerator = config[ADD_ACTIONS] + // When adding a new element you don't need the oldObj + const action = actionGenerator( + newObj[key][index], + parseInt(index, 10) + ) + + if (action) { + if (Array.isArray(action)) { + addActions = addActions.concat(action) + } else { + addActions.push(action) + } + } + } else if ( + config[CHANGE_ACTIONS] && + isChangeAction(arrayDelta, index) + ) { + const actionGenerator = config[CHANGE_ACTIONS] + // When changing an existing element you need both old + new + const action = actionGenerator( + oldObj[key][index], + newObj[key][index], + parseInt(index, 10) + ) + + if (action) { + if (Array.isArray(action)) { + changeActions = changeActions.concat(action) + } else { + changeActions.push(action) + } + } + } else if ( + config[REMOVE_ACTIONS] && + isRemoveAction(arrayDelta, index) + ) { + const realIndex = index.replace('_', '') + const actionGenerator = config[REMOVE_ACTIONS] + // When removing an existing element you don't need the newObj + const action = actionGenerator( + oldObj[key][realIndex], + parseInt(realIndex, 10) + ) + + if (action) removeActions.push(action) + } + }) + } + + return changeActions.concat(removeActions, addActions) + } +} diff --git a/packages/sync-actions/src/utils/create-map-action-group.ts b/packages/sync-actions/src/utils/create-map-action-group.ts new file mode 100644 index 000000000..30b27c372 --- /dev/null +++ b/packages/sync-actions/src/utils/create-map-action-group.ts @@ -0,0 +1,65 @@ +// Array of action groups which need to be allowed or ignored. +// Example: +// [ +// { type: 'base', group: 'ignore' }, +// { type: 'prices', group: 'allow' }, +// { type: 'variants', group: 'ignore' }, +// ] +import { Delta } from './diffpatcher' + +import { + ActionGroup, + SyncActionConfig, + UpdateAction, +} from '../types/update-actions' + +type MapActionGroup = ( + type: string, + fn: () => Array +) => Array + +export type MapAction = ( + mapActionGroup: MapActionGroup, + syncActionConfig?: SyncActionConfig +) => ( + diff: Delta, + newObj: any, + oldObj: any, + config?: any +) => Array + +export type ActionMap = ( + diff: Delta, + oldObj: any, + newObj: any +) => Array + +export type ActionMapBase = ( + diff: Delta, + oldObj: any, + newObj: any, + config?: { shouldOmitEmptyString?: boolean; [key: string]: any } +) => Array + +export default function createMapActionGroup( + actionGroups: Array = [] +): MapActionGroup { + return function mapActionGroup( + type: string, + fn: () => Array + ): Array { + if (!Object.keys(actionGroups).length) return fn() + + const found = actionGroups.find((c) => c.type === type) + if (!found) return [] + + // Keep `black` for backwards compatibility. + if (found.group === 'ignore' || (found as any).group === 'black') return [] + // Keep `white` for backwards compatibility. + if (found.group === 'allow' || (found as any).group === 'white') return fn() + + throw new Error( + `Action group '${found.group}' not supported. Use either "allow" or "ignore".` + ) + } +} diff --git a/packages/sync-actions/src/utils/diffpatcher.ts b/packages/sync-actions/src/utils/diffpatcher.ts new file mode 100644 index 000000000..3e63e73a6 --- /dev/null +++ b/packages/sync-actions/src/utils/diffpatcher.ts @@ -0,0 +1,73 @@ +// jsondiffpatch does not yet handle minified UMD builds +// with es6 modules so we use require instead below +// TODO create an issue here https://github.com/benjamine/jsondiffpatch/issues/new +const DiffPatcher = require('jsondiffpatch').DiffPatcher +import { Delta as DiffDelta } from 'jsondiffpatch' + +export function objectHash(obj: any, index: any) { + const objIndex = `$$index:${index}` + return typeof obj === 'object' && obj !== null + ? obj.id || obj.name || obj.url || objIndex + : objIndex +} + +const diffpatcher = new DiffPatcher({ + objectHash, + arrays: { + // detect items moved inside the array + detectMove: true, + + // value of items moved is not included in deltas + includeValueOnMove: false, + }, + textDiff: { + /** + * jsondiffpatch uses a very fine-grained diffing algorithm for long strings to easily identify + * what changed between strings. However, we don't actually care about what changed, just + * if the string changed at all. So we set the minimum length to diff to a very large number to avoid + * using the very slow algorithm. + * See https://github.com/benjamine/jsondiffpatch/blob/master/docs/deltas.md#text-diffs. + */ + minLength: Number.MAX_SAFE_INTEGER, + }, +}) + +export type Delta = DiffDelta | undefined + +export function diff(oldObj: any, newObj: any): Delta { + return diffpatcher.diff(oldObj, newObj) +} + +export function patch(obj: any, delta: any) { + return diffpatcher.patch(obj, delta) +} + +export function getDeltaValue(arr: Array | any, originalObject?: any) { + if (!Array.isArray(arr)) + throw new Error('Expected array to extract delta value') + + if (arr.length === 1) return arr[0] // new + + if (arr.length === 2) return arr[1] // update + + if (arr.length === 3 && arr[2] === 0) return undefined // delete + + if (arr.length === 3 && arr[2] === 2) { + // text diff + if (!originalObject) + throw new Error( + 'Cannot apply patch to long text diff. Missing original object.' + ) + // try to apply patch to given object based on delta value + return patch(originalObject, arr) + } + + if (arr.length === 3 && arr[2] === 3) + // array move + throw new Error( + 'Detected an array move, it should not happen as ' + + '`includeValueOnMove` should be set to false' + ) + + throw new Error(`Got unsupported number ${arr[2]} in delta value`) +} diff --git a/packages/sync-actions/src/utils/extract-matching-pairs.ts b/packages/sync-actions/src/utils/extract-matching-pairs.ts new file mode 100644 index 000000000..b871ff651 --- /dev/null +++ b/packages/sync-actions/src/utils/extract-matching-pairs.ts @@ -0,0 +1,21 @@ +export default function extractMatchingPairs( + hashMap: any, + key: string, + before: any, + now: any +) { + let oldObjPos + let newObjPos + let oldObj + let newObj + + if (hashMap[key]) { + oldObjPos = hashMap[key][0] + newObjPos = hashMap[key][1] + if (before && before[oldObjPos]) oldObj = before[oldObjPos] + + if (now && now[newObjPos]) newObj = now[newObjPos] + } + + return { oldObj, newObj } +} diff --git a/packages/sync-actions/src/utils/find-matching-pairs.ts b/packages/sync-actions/src/utils/find-matching-pairs.ts new file mode 100644 index 000000000..8e9fb7a33 --- /dev/null +++ b/packages/sync-actions/src/utils/find-matching-pairs.ts @@ -0,0 +1,48 @@ +import { Delta } from './diffpatcher' + +const REGEX_NUMBER = new RegExp(/^\d+$/) +const REGEX_UNDERSCORE_NUMBER = new RegExp(/^_\d+$/) + +function preProcessCollection(collection: Array = [], identifier = 'id') { + return collection.reduce( + (acc, currentValue, currentIndex) => { + acc.refByIndex[String(currentIndex)] = currentValue[identifier] + acc.refByIdentifier[currentValue[identifier]] = String(currentIndex) + return acc + }, + { + refByIndex: {}, + refByIdentifier: {}, + } + ) +} + +// creates a hash of a location of an item in collection1 and collection2 +export default function findMatchingPairs( + diff: Delta | undefined, + before: Array = [], + now: Array = [], + identifier: string = 'id' +) { + const result = {} + const { + refByIdentifier: beforeObjRefByIdentifier, + refByIndex: beforeObjRefByIndex, + } = preProcessCollection(before, identifier) + const { + refByIdentifier: nowObjRefByIdentifier, + refByIndex: nowObjRefByIndex, + } = preProcessCollection(now, identifier) + diff && + Object.entries(diff).forEach(([key, item]) => { + if (REGEX_NUMBER.test(key)) { + const matchingIdentifier = nowObjRefByIndex[key] + result[key] = [beforeObjRefByIdentifier[matchingIdentifier], key] + } else if (REGEX_UNDERSCORE_NUMBER.test(key)) { + const index = key.substring(1) + const matchingIdentifier = beforeObjRefByIndex[index] + result[key] = [index, nowObjRefByIdentifier[matchingIdentifier]] + } + }) + return result +} diff --git a/packages/sync-actions/src/zones-actions.ts b/packages/sync-actions/src/zones-actions.ts new file mode 100644 index 000000000..978cd3040 --- /dev/null +++ b/packages/sync-actions/src/zones-actions.ts @@ -0,0 +1,68 @@ +import { buildBaseAttributesActions } from './utils/common-actions' +import createBuildArrayActions, { + ADD_ACTIONS, + CHANGE_ACTIONS, + REMOVE_ACTIONS, +} from './utils/create-build-array-actions' +import { ActionMap, ActionMapBase } from './utils/create-map-action-group' +import { Location } from '@commercetools/platform-sdk' +import { UpdateAction } from './types/update-actions' + +export const baseActionsList: Array = [ + { action: 'changeName', key: 'name' }, + { action: 'setDescription', key: 'description' }, + { action: 'setKey', key: 'key' }, +] + +const hasLocation = (locations: Array, otherLocation: Location) => + locations.some((location) => location.country === otherLocation.country) + +export const actionsMapBase: ActionMapBase = (diff, oldObj, newObj, config) => { + return buildBaseAttributesActions({ + actions: baseActionsList, + diff, + oldObj, + newObj, + shouldOmitEmptyString: config?.shouldOmitEmptyString, + }) +} + +export const actionsMapLocations: ActionMap = (diff, oldObj, newObj) => { + const handler = createBuildArrayActions('locations', { + [ADD_ACTIONS]: (newLocation) => ({ + action: 'addLocation', + location: newLocation, + }), + [REMOVE_ACTIONS]: (oldLocation) => + // We only add the action if the location is not included in the new object. + !hasLocation(newObj.locations, oldLocation) + ? { + action: 'removeLocation', + location: oldLocation, + } + : null, + [CHANGE_ACTIONS]: (oldLocation, newLocation) => { + const result: Array = [] + + // We only remove the location in case that the oldLocation is not + // included in the new object + if (!hasLocation(newObj.locations, oldLocation)) + result.push({ + action: 'removeLocation', + location: oldLocation, + }) + + // We only add the location in case that the newLocation was not + // included in the old object + if (!hasLocation(oldObj.locations, newLocation)) + result.push({ + action: 'addLocation', + location: newLocation, + }) + + return result + }, + }) + + return handler(diff, oldObj, newObj) +} diff --git a/packages/sync-actions/src/zones.ts b/packages/sync-actions/src/zones.ts new file mode 100644 index 000000000..2c6f4ad6d --- /dev/null +++ b/packages/sync-actions/src/zones.ts @@ -0,0 +1,56 @@ +import { Zone, ZoneUpdateAction } from '@commercetools/platform-sdk' +import { + ActionGroup, + SyncActionConfig, + SyncAction, + UpdateAction, +} from './types/update-actions' +import createBuildActions from './utils/create-build-actions' +import createMapActionGroup, { + MapAction, +} from './utils/create-map-action-group' +import { diff } from './utils/diffpatcher' +import * as zonesActions from './zones-actions' + +export const actionGroups = ['base', 'locations'] + +const createZonesMapActions: MapAction = (mapActionGroup, syncActionConfig) => { + return function doMapActions(diff, newObj, oldObj) { + const allActions: Array> = [] + allActions.push( + mapActionGroup('base', () => + zonesActions.actionsMapBase(diff, oldObj, newObj, syncActionConfig) + ) + ) + allActions.push( + mapActionGroup('locations', () => + zonesActions.actionsMapLocations(diff, oldObj, newObj) + ).flat() + ) + return allActions.flat() + } +} + +export const createSyncZones = ( + actionGroupList?: Array, + syncActionConfig?: SyncActionConfig +): SyncAction => { + // config contains information about which action groups + // are allowed or ignored + + // createMapActionGroup returns function 'mapActionGroup' that takes params: + // - action group name + // - callback function that should return a list of actions that correspond + // to the for the action group + + // this resulting function mapActionGroup will call the callback function + // for allowed action groups and return the return value of the callback + // It will return an empty array for ignored action groups + const mapActionGroup = createMapActionGroup(actionGroupList) + const doMapActions = createZonesMapActions(mapActionGroup, syncActionConfig) + const buildActions = createBuildActions( + diff, + doMapActions + ) + return { buildActions } +} diff --git a/packages/sync-actions/test/__snapshots__/product-types-sync-attribute-hints.spec.ts.snap b/packages/sync-actions/test/__snapshots__/product-types-sync-attribute-hints.spec.ts.snap new file mode 100644 index 000000000..26c6294f1 --- /dev/null +++ b/packages/sync-actions/test/__snapshots__/product-types-sync-attribute-hints.spec.ts.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`product type hints attribute enum values with previous with changes when is localized should match snapshot 1`] = ` +[ + { + "action": "changeEnumKey", + "attributeName": "attribute-name", + "key": "enum-key", + "newKey": "next-key", + }, + { + "action": "changeLocalizedEnumValueLabel", + "attributeName": "attribute-name", + "newValue": { + "key": "next-key", + "label": "next-label", + }, + }, +] +`; + +exports[`product type hints attribute enum values with previous with changes when is not localized should match snapshot 1`] = ` +[ + { + "action": "changeEnumKey", + "attributeName": "attribute-name", + "key": "enum-key", + "newKey": "next-key", + }, + { + "action": "changePlainEnumValueLabel", + "attributeName": "attribute-name", + "newValue": { + "key": "next-key", + "label": "next-label", + }, + }, +] +`; + +exports[`product type hints attribute enum values with previous with changes when removing, adding, and editing (in a single batch of actions) should match snapshot 1`] = ` +[ + { + "action": "removeEnumValues", + "attributeName": "attribute-enum-with-2-enum-values-to-remove", + "keys": [ + "enum-key-1", + "enum-key-2", + ], + }, + { + "action": "changeEnumKey", + "attributeName": "attribute-name", + "key": "enum-key", + "newKey": "next-enum-draft-item", + }, + { + "action": "changePlainEnumValueLabel", + "attributeName": "attribute-name", + "newValue": { + "key": "next-enum-draft-item", + "label": undefined, + }, + }, + { + "action": "addPlainEnumValue", + "attributeName": "attribute-name", + "value": { + "key": "new-enum-draft-item", + "label": "new-enum-draft-item", + }, + }, +] +`; + +exports[`product type hints attribute enum values without previous should match snapshot 1`] = ` +[ + { + "action": "addPlainEnumValue", + "attributeName": "attribute-name", + "value": { + "key": "enum-key", + "label": "enum-label", + }, + }, +] +`; + +exports[`product type hints attribute enum values without previous when is localized should match snapshot 1`] = ` +[ + { + "action": "addLocalizedEnumValue", + "attributeName": "attribute-name", + "value": { + "key": "enum-key", + "label": "enum-label", + }, + }, +] +`; + +exports[`product type hints attribute enum values without previous when is truly localized should match snapshot 1`] = ` +[ + { + "action": "changeLocalizedEnumValueLabel", + "attributeName": "attribute-name", + "newValue": { + "key": "enum-key", + "label": { + "en-GB": "uk-label-new", + }, + }, + }, +] +`; + +exports[`product type hints attribute hints with previous with next with changes should match snapshot 1`] = ` +[ + { + "action": "changeLabel", + "attributeName": "attribute-name", + "label": { + "en": "next-attribute-label", + }, + }, + { + "action": "setInputTip", + "attributeName": "attribute-name", + "inputTip": { + "en": "next-input-tip", + }, + }, + { + "action": "changeInputHint", + "attributeName": "attribute-name", + "newValue": "MultiLine", + }, + { + "action": "changeIsSearchable", + "attributeName": "attribute-name", + "isSearchable": true, + }, + { + "action": "changeAttributeConstraint", + "attributeName": "attribute-name", + "newValue": "None", + }, +] +`; + +exports[`product type hints attribute hints with previous with next with no changes should match snapshot 1`] = `[]`; + +exports[`product type hints attribute hints with previous without next should match snapshot 1`] = ` +[ + { + "action": "removeAttributeDefinition", + "name": "attribute-name", + }, +] +`; + +exports[`product type hints attribute hints without previous should match snapshot 1`] = ` +[ + { + "action": "addAttributeDefinition", + "attribute": { + "attributeConstraint": "SameForAll", + "inputHint": "SingleLine", + "inputTip": { + "en": "input-hint", + }, + "isRequired": false, + "isSearchable": false, + "label": { + "en": "attribute-label", + }, + "name": "attribute-name", + "type": { + "name": "text", + }, + }, + }, +] +`; diff --git a/packages/sync-actions/test/attribute-groups-sync.spec.ts b/packages/sync-actions/test/attribute-groups-sync.spec.ts new file mode 100644 index 000000000..ac49f9d02 --- /dev/null +++ b/packages/sync-actions/test/attribute-groups-sync.spec.ts @@ -0,0 +1,205 @@ +import { createSyncAttributeGroups } from '../src' +import { baseActionsList } from '../src/attribute-groups-actions' + +describe('Exports', () => { + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'setDescription', key: 'description' }, + ]) + }) +}) + +describe('Actions', () => { + let attributeGroupSync = createSyncAttributeGroups() + beforeEach(() => { + attributeGroupSync = createSyncAttributeGroups() + }) + + test('should build `changeName` action', () => { + const before = { + name: { 'en-GB': 'John' }, + } + const now = { + name: { 'en-GB': 'Robert' }, + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [{ action: 'changeName', name: now.name }] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: { 'en-GB': 'some description' }, + } + const now = { + description: { 'en-GB': 'some updated description' }, + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: now.description, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setKey` action', () => { + const before = { + key: 'some-key', + } + const now = { + key: 'new-key', + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { + action: 'setKey', + key: now.key, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('`addAttribute`', () => { + test('should build `addAttribute` action with one attribute', () => { + const before = { + attributes: [], + } + const now = { attributes: [{ key: 'Size' }] } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'addAttribute', attribute: now.attributes[0] }, + ] + expect(actual).toEqual(expected) + }) + test('should build `addAttribute` action with two attributes', () => { + const before = { attributes: [] } + const now = { attributes: [{ key: 'Size' }, { key: 'Brand' }] } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'addAttribute', attribute: now.attributes[0] }, + { action: 'addAttribute', attribute: now.attributes[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('`removeAttribute`', () => { + test('should build `removeAttribute` action removing one attribute', () => { + const before = { + attributes: [{ key: 'Size' }, { key: 'Brand' }], + } + const now = { attributes: [{ key: 'Size' }] } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[1] }, + ] + expect(actual).toEqual(expected) + }) + test('should build `removeAttribute` action removing two attributes', () => { + const before = { + attributes: [{ key: 'Size' }, { key: 'Brand' }], + } + const now = { attributes: [] } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[0] }, + { action: 'removeAttribute', attribute: before.attributes[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Swap attributes (create one + delete one)', () => { + test('should build `removeAttribute` and `addAttribute`', () => { + const before = { attributes: [{ key: 'Size' }] } + const now = { attributes: [{ key: 'Brand' }] } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[0] }, + { action: 'addAttribute', attribute: now.attributes[0] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Multiple actions', () => { + test('should build multiple actions for required changes', () => { + const before = { + attributes: [{ key: 'Size' }, { key: 'Brand' }], + } + const now = { + attributes: [{ key: 'Quality' }, { key: 'Brand' }, { key: 'color' }], + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[0] }, + { action: 'addAttribute', attribute: now.attributes[0] }, + { action: 'addAttribute', attribute: now.attributes[2] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Delete first attributes', () => { + test('should build multiple actions for required changes', () => { + const before = { + attributes: [{ key: 'Size' }, { key: 'Brand' }, { key: 'Color' }], + } + const now = { + attributes: [{ key: 'Color' }], + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[0] }, + { action: 'removeAttribute', attribute: before.attributes[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Delete multiple attributes', () => { + test('should build multiple actions for required changes', () => { + const before = { + attributes: [ + { key: 'Size' }, + { key: 'Brand' }, + { key: 'Quality' }, + { key: 'Color' }, + { key: 'Model' }, + { key: 'attr-1' }, + ], + } + const now = { + attributes: [ + { key: 'Brand' }, + { key: 'Quality' }, + { key: 'Model' }, + { key: 'attr-2' }, + ], + } + + const actual = attributeGroupSync.buildActions(now, before) + const expected = [ + { action: 'removeAttribute', attribute: before.attributes[0] }, + { action: 'removeAttribute', attribute: before.attributes[3] }, + { action: 'addAttribute', attribute: now.attributes[3] }, + { action: 'removeAttribute', attribute: before.attributes[5] }, + ] + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/sync-actions/test/cart-discounts-sync.spec.ts b/packages/sync-actions/test/cart-discounts-sync.spec.ts new file mode 100644 index 000000000..52a4b5071 --- /dev/null +++ b/packages/sync-actions/test/cart-discounts-sync.spec.ts @@ -0,0 +1,451 @@ +import { actionGroups, createSyncCartDiscounts } from '../src/cart-discounts' +import { baseActionsList } from '../src/cart-discounts-actions' +import { DeepPartial } from '../src/types/update-actions' +import { CartDiscountDraft } from '@commercetools/platform-sdk/src' + +describe('Cart Discounts Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeIsActive` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeIsActive', key: 'isActive' }]) + ) + }) + + test('should contain `changeName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeName', key: 'name' }]) + ) + }) + + test('should contain `changeCartPredicate` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeCartPredicate', + key: 'cartPredicate', + }, + ]) + ) + }) + + test('should contain `changeSortOrder` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeSortOrder', key: 'sortOrder' }, + ]) + ) + }) + + test('should contain `changeValue` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeValue', key: 'value' }]) + ) + }) + + test('should contain `changeRequiresDiscountCode` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeRequiresDiscountCode', + key: 'requiresDiscountCode', + }, + ]) + ) + }) + + test('should contain `changeTarget` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeTarget', key: 'target' }]) + ) + }) + + test('should contain `setDescription` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setDescription', + key: 'description', + }, + ]) + ) + }) + + test('should contain `setValidFrom` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidFrom', key: 'validFrom' }]) + ) + }) + + test('should contain `setValidUntil` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidUntil', key: 'validUntil' }]) + ) + }) + + test('should contain `changeStackingMode` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeStackingMode', + key: 'stackingMode', + }, + ]) + ) + }) + + test('should contain `setKey` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setKey', + key: 'key', + }, + ]) + ) + }) + }) +}) + +describe('Cart Discounts Actions', () => { + let cartDiscountsSync = createSyncCartDiscounts() + beforeEach(() => { + cartDiscountsSync = createSyncCartDiscounts() + }) + + test('should build the `changeIsActive` action', () => { + const before = { + isActive: false, + } + + const now = { + isActive: true, + } + + const expected = [ + { + action: 'changeIsActive', + isActive: true, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeName` action', () => { + const before = { + name: { en: 'en-name-before', de: 'de-name-before' }, + } + + const now = { + name: { en: 'en-name-now', de: 'de-name-now' }, + } + + const expected = [ + { + action: 'changeName', + name: { en: 'en-name-now', de: 'de-name-now' }, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeCartPredicate` action', () => { + const before = { + cartPredicate: '1=1', + } + + const now = { + cartPredicate: 'sku="test-sku"', + } + + const expected = [ + { + action: 'changeCartPredicate', + cartPredicate: 'sku="test-sku"', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeSortOrder` action', () => { + const before = { + sortOrder: '0.1', + } + + const now = { + sortOrder: '0.2', + } + + const expected = [ + { + action: 'changeSortOrder', + sortOrder: '0.2', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeValue` action', () => { + const before: DeepPartial = { + value: { + type: 'relative', + permyriad: 100, + }, + } + + const now: DeepPartial = { + value: { + type: 'relative', + permyriad: 200, + }, + } + + const expected = [ + { + action: 'changeValue', + value: { + type: 'relative', + permyriad: 200, + }, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeRequiresDiscountCode` action', () => { + const before = { + requiresDiscountCode: false, + } + + const now = { + requiresDiscountCode: true, + } + + const expected = [ + { + action: 'changeRequiresDiscountCode', + requiresDiscountCode: true, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeTarget` action', () => { + const before: DeepPartial = { + target: { + type: 'customLineItems', + predicate: 'sku="sku-a"', + }, + } + + const now: DeepPartial = { + target: { + type: 'lineItems', + predicate: 'sku="sku-b"', + }, + } + + const expected = [ + { + action: 'changeTarget', + target: { + type: 'lineItems', + predicate: 'sku="sku-b"', + }, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setDescription` action', () => { + const before = { + description: { + en: 'en-description-before', + de: 'de-description-before', + }, + } + + const now = { + description: { en: 'en-description-now', de: 'de-description-now' }, + } + + const expected = [ + { + action: 'setDescription', + description: { en: 'en-description-now', de: 'de-description-now' }, + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidFrom` action', () => { + const before = { + validFrom: 'date1', + } + + const now = { + validFrom: 'date2', + } + + const expected = [ + { + action: 'setValidFrom', + validFrom: 'date2', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidUntil` action', () => { + const before = { + validUntil: 'date1', + } + + const now = { + validUntil: 'date2', + } + + const expected = [ + { + action: 'setValidUntil', + validUntil: 'date2', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + test('should build the `setValidFromAndUntil` action when both `validFrom` and `validUntil` exist', () => { + const before = { + validFrom: 'date-1-From', + validUntil: 'date-1-Until', + } + + const now = { + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + } + + const expected = [ + { + action: 'setValidFromAndUntil', + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + test('should build the `changeStackingMode` action', () => { + const before = { + stackingMode: 'Stacking', + } + + const now = { + stackingMode: 'StopAfterThisDiscount', + } + + const expected = [ + { + action: 'changeStackingMode', + stackingMode: 'StopAfterThisDiscount', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = cartDiscountsSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = cartDiscountsSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build the `setKey` action', () => { + const before = { + key: 'key-before', + } + + const now = { + key: 'key-now', + } + + const expected = [ + { + action: 'setKey', + key: 'key-now', + }, + ] + const actual = cartDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/category-sync.spec.ts b/packages/sync-actions/test/category-sync.spec.ts new file mode 100644 index 000000000..5a2e50ecb --- /dev/null +++ b/packages/sync-actions/test/category-sync.spec.ts @@ -0,0 +1,282 @@ +import { createSyncCategories, actionGroups } from '../src/categories' +import { DeepPartial } from '../src/types/update-actions' +import { + baseActionsList, + metaActionsList, + referenceActionsList, +} from '../src/category-actions' +import { CategoryDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual([ + 'base', + 'references', + 'meta', + 'custom', + 'assets', + ]) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'changeSlug', key: 'slug' }, + { action: 'setDescription', key: 'description' }, + { action: 'changeOrderHint', key: 'orderHint' }, + { action: 'setExternalId', key: 'externalId' }, + { action: 'setKey', key: 'key' }, + ]) + }) + + test('correctly define meta actions list', () => { + expect(metaActionsList).toEqual([ + { action: 'setMetaTitle', key: 'metaTitle' }, + { action: 'setMetaKeywords', key: 'metaKeywords' }, + { action: 'setMetaDescription', key: 'metaDescription' }, + ]) + }) + + test('correctly define reference actions list', () => { + expect(referenceActionsList).toEqual([ + { action: 'changeParent', key: 'parent' }, + ]) + }) +}) + +describe('Actions', () => { + let categorySync = createSyncCategories() + beforeEach(() => { + categorySync = createSyncCategories() + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = categorySync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('assets', () => { + test('should build "addAsset" action with empty assets', () => { + const before = { + assets: [], + } + const now = { + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: now.assets[0], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "addAsset" action with existing assets', () => { + const existingAsset = { + key: 'existing', + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + } + const newAsset = { + key: 'new', + sources: [ + { + uri: 'http://example.org/content/product-manual.gif', + }, + ], + } + const before = { + assets: [existingAsset], + } + const now = { + assets: [existingAsset, newAsset], + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: newAsset, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "removeAsset" action with assetId prop', () => { + const before = { + assets: [ + { + id: 'c136c9dc-51e8-40fe-8e2e-2a4c159f3358', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + } + const now = { + assets: [], + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetId: before.assets[0].id, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "removeAsset" action with assetKey prop', () => { + const before = { + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + } + const now = { + assets: [], + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetKey: before.assets[0].key, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should throw no exception on missing assets', () => { + const before = { + assets: [], + } + const now = {} + const actual = categorySync.buildActions(now, before) + const expected = [] + expect(actual).toEqual(expected) + }) + }) + + test('should build "removeAsset" and "addAsset" action when asset is changed', () => { + const initialAsset = { + key: 'asset-key', + name: { + en: 'asset name ', + }, + } + const changedName = { + name: { + en: 'asset name ', + de: 'Asset Name', + }, + } + const changedAsset = { ...initialAsset, ...changedName } + const before = { + assets: [initialAsset], + } + const now = { + assets: [changedAsset], + } + const actual = categorySync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetKey: before.assets[0].key, + }, + { + action: 'addAsset', + asset: changedAsset, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/channels-sync-.spec.ts b/packages/sync-actions/test/channels-sync-.spec.ts new file mode 100644 index 000000000..f2c891378 --- /dev/null +++ b/packages/sync-actions/test/channels-sync-.spec.ts @@ -0,0 +1,225 @@ +import { createSyncChannels, actionGroups } from '../src/channels' +import { baseActionsList } from '../src/channels-actions' +import { DeepPartial } from '../src/types/update-actions' +import { ChannelDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeKey` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeKey', key: 'key' }]) + ) + }) + + test('should contain `changeName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeName', key: 'name' }]) + ) + }) + + test('should contain `changeDescription` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeDescription', + key: 'description', + }, + ]) + ) + }) + + test('should contain `setAddress` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setAddress', + key: 'address', + }, + ]) + ) + }) + + test('should contain `setGeoLocation` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setGeoLocation', + key: 'geoLocation', + }, + ]) + ) + }) + + test('should contain `setRoles` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setRoles', + key: 'roles', + }, + ]) + ) + }) + }) +}) + +describe('Actions', () => { + let channelsSync = createSyncChannels() + beforeEach(() => { + channelsSync = createSyncChannels() + }) + + test('should build `changeKey` action', () => { + const before = { key: 'keyBefore' } + const now = { key: 'keyAfter' } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'changeKey', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeName` action', () => { + const before = { + name: { 'en-GB': 'nameBefore' }, + } + const now = { name: { 'en-GB': 'nameAfter' } } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'changeName', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeDescription` action', () => { + const before = { description: { 'en-GB': 'descriptionBefore' } } + const now = { description: { 'en-GB': 'descriptionAfter' } } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'changeDescription', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setAddress` action', () => { + const before: DeepPartial = { + address: { country: 'addressBefore' }, + } + const now = { address: { country: 'addressAfter' } } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'setAddress', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setGeoLocation` action', () => { + const before: DeepPartial = { + geoLocation: { type: 'Point', coordinates: [123] }, + } + const now: DeepPartial = { + geoLocation: { type: 'Point', coordinates: [456] }, + } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'setGeoLocation', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setRoles` action', () => { + const before = { roles: ['exists'] } + const now = { roles: ['exists', 'new'] } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'setRoles', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = channelsSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = channelsSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/customer-group-sync.spec.ts b/packages/sync-actions/test/customer-group-sync.spec.ts new file mode 100644 index 000000000..62ea48d26 --- /dev/null +++ b/packages/sync-actions/test/customer-group-sync.spec.ts @@ -0,0 +1,141 @@ +import { actionGroups, createSyncCustomerGroup } from '../src/customer-group' +import { baseActionsList } from '../src/customer-group-actions' +import { DeepPartial } from '../src/types/update-actions' +import { + CustomerGroup, + CustomerGroupDraft, +} from '@commercetools/platform-sdk/src' + +describe('Customer Groups Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeName', key: 'name' }]) + ) + }) + + test('should contain `setKey` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setKey', + key: 'key', + }, + ]) + ) + }) + }) +}) + +describe('Customer Groups Actions', () => { + let customerGroupSync = createSyncCustomerGroup() + beforeEach(() => { + customerGroupSync = createSyncCustomerGroup() + }) + + test('should build the `changeName` action', () => { + const before: DeepPartial = { + name: 'en-name-before', + } + + const now: DeepPartial = { + name: 'en-name-now', + } + + const expected = [ + { + action: 'changeName', + name: 'en-name-now', + }, + ] + const actual = customerGroupSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setKey` action', () => { + const before = { + key: 'foo-key', + } + + const now = { + key: 'bar-key', + } + + const expected = [ + { + action: 'setKey', + key: 'bar-key', + }, + ] + const actual = customerGroupSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = customerGroupSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = customerGroupSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/customer-sync.spec.ts b/packages/sync-actions/test/customer-sync.spec.ts new file mode 100644 index 000000000..4f7503c44 --- /dev/null +++ b/packages/sync-actions/test/customer-sync.spec.ts @@ -0,0 +1,647 @@ +import { actionGroups, createSyncCustomers } from '../src/customers' +import { + baseActionsList, + setDefaultBaseActionsList, + referenceActionsList, +} from '../src/customer-actions' +import { DeepPartial } from '../src/types/update-actions' +import { Customer } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual([ + 'base', + 'references', + 'addresses', + 'custom', + 'authenticationModes', + ]) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'setSalutation', key: 'salutation' }, + { action: 'changeEmail', key: 'email' }, + { action: 'setFirstName', key: 'firstName' }, + { action: 'setLastName', key: 'lastName' }, + { action: 'setMiddleName', key: 'middleName' }, + { action: 'setTitle', key: 'title' }, + { action: 'setCustomerNumber', key: 'customerNumber' }, + { action: 'setExternalId', key: 'externalId' }, + { action: 'setCompanyName', key: 'companyName' }, + { action: 'setDateOfBirth', key: 'dateOfBirth' }, + { action: 'setLocale', key: 'locale' }, + { action: 'setVatId', key: 'vatId' }, + { + action: 'setStores', + key: 'stores', + }, + { + action: 'setKey', + key: 'key', + }, + ]) + }) + + test('correctly define base set default actions list', () => { + expect(setDefaultBaseActionsList).toEqual([ + { + action: 'setDefaultBillingAddress', + key: 'defaultBillingAddressId', + actionKey: 'addressId', + }, + { + action: 'setDefaultShippingAddress', + key: 'defaultShippingAddressId', + actionKey: 'addressId', + }, + ]) + }) + + test('correctly define reference actions list', () => { + expect(referenceActionsList).toEqual([ + { action: 'setCustomerGroup', key: 'customerGroup' }, + ]) + }) +}) + +describe('Actions', () => { + let customerSync = createSyncCustomers() + beforeEach(() => { + customerSync = createSyncCustomers() + }) + + test('should build `setSalutation` action', () => { + const before = { + salutation: 'Best', + } + const now = { + salutation: 'Dear', + } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'setSalutation', salutation: now.salutation }] + expect(actual).toEqual(expected) + }) + + test('should build `changeEmail` action', () => { + const before = { + email: 'john@doe.com', + } + const now = { + email: 'jessy@jones.com', + } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'changeEmail', email: now.email }] + expect(actual).toEqual(expected) + }) + + test('should build `setDefaultBillingAddress` action', () => { + const before: DeepPartial = { + defaultBillingAddressId: 'abc123', + } + const now = { + defaultBillingAddressId: 'def456', + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'setDefaultBillingAddress', + addressId: now.defaultBillingAddressId, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setDefaultShippingAddress` action', () => { + const before = { + defaultShippingAddressId: 'abc123', + } + const now = { + defaultShippingAddressId: 'def456', + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'setDefaultShippingAddress', + addressId: now.defaultShippingAddressId, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `addAddress` action', () => { + const before = { addresses: [] } + const now = { + addresses: [{ streetName: 'some name', streetNumber: '5' }], + } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'addAddress', address: now.addresses[0] }] + expect(actual).toEqual(expected) + }) + + test('should build `addAddress` action before `setDefaultShippingAddress`', () => { + const before = { addresses: [] } + const now = { + addresses: [{ streetName: 'some name', streetNumber: '5' }], + defaultShippingAddressId: 'def456', + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { action: 'addAddress', address: now.addresses[0] }, + { + action: 'setDefaultShippingAddress', + addressId: now.defaultShippingAddressId, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeAddress` action', () => { + const before = { + addresses: [ + { + id: 'somelongidgoeshere199191', + streetName: 'some name', + streetNumber: '5', + }, + ], + } + const now = { + addresses: [ + { + id: 'somelongidgoeshere199191', + streetName: 'some different name', + streetNumber: '5', + }, + ], + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'changeAddress', + addressId: before.addresses[0].id, + address: now.addresses[0], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `removeAddress` action', () => { + const before = { + addresses: [{ id: 'somelongidgoeshere199191' }], + } + const now = { addresses: [] } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'removeAddress', + addressId: before.addresses[0].id, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build complex mixed actions', () => { + const before = { + addresses: [ + { + id: 'addressId1', + title: 'mr', + streetName: 'address 1 street', + postalCode: 'postal code 1', + }, + { + id: 'addressId2', + title: 'mr', + streetName: 'address 2 street', + postalCode: 'postal code 2', + }, + { + id: 'addressId4', + title: 'mr', + streetName: 'address 4 street', + postalCode: 'postal code 4', + }, + ], + } + const now = { + addresses: [ + { + id: 'addressId1', + title: 'mr', + streetName: 'address 1 street changed', // CHANGED + postalCode: 'postal code 1', + }, + // REMOVED ADDRESS 2 + { + // UNCHANGED ADDRESS 4 + id: 'addressId4', + title: 'mr', + streetName: 'address 4 street', + postalCode: 'postal code 4', + }, + { + // ADD NEW ADDRESS + id: 'addressId3', + title: 'mr', + streetName: 'address 3 street', + postalCode: 'postal code 3', + }, + ], + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + // CHANGE ACTIONS FIRST + action: 'changeAddress', + addressId: 'addressId1', + address: now.addresses[0], + }, + { + // REMOVE ACTIONS NEXT + action: 'removeAddress', + addressId: 'addressId2', + }, + { + // CREATE ACTIONS LAST + action: 'addAddress', + address: now.addresses[2], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `addBillingAddressId` action', () => { + const addressId = 'addressId' + const before = { billingAddressIds: [] } + const now = { + billingAddressIds: [addressId], + } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'addBillingAddressId', addressId }] + expect(actual).toEqual(expected) + }) + + test('should build `removeBillingAddressId` action', () => { + const addressId = 'addressId' + const before = { + billingAddressIds: [addressId], + } + const now = { billingAddressIds: [] } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'removeBillingAddressId', addressId }] + expect(actual).toEqual(expected) + }) + + test('should build both `add-` and `removeBillingAddressId` actions', () => { + const before = { + billingAddressIds: ['remove', 'keep', 'remove2'], + } + const now = { + billingAddressIds: ['keep', 'new'], + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'removeBillingAddressId', + addressId: 'remove', + }, + { + action: 'removeBillingAddressId', + addressId: 'remove2', + }, + { + action: 'addBillingAddressId', + addressId: 'new', + }, + ] + expect(actual).toEqual(expected) + }) + test('should build `addShippingAddressId` action', () => { + const addressId = 'addressId' + const before = { shippingAddressIds: [] } + const now = { + shippingAddressIds: [addressId], + } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'addShippingAddressId', addressId }] + expect(actual).toEqual(expected) + }) + + test('should build `removeShippingAddressId` action', () => { + const addressId = 'addressId' + const before = { + shippingAddressIds: [addressId], + } + const now = { shippingAddressIds: [] } + + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'removeShippingAddressId', addressId }] + expect(actual).toEqual(expected) + }) + + test('should build both `add-` and `removeShippingAddressId` actions', () => { + const before = { + shippingAddressIds: ['remove', 'keep', 'remove2'], + } + const now = { + shippingAddressIds: ['keep', 'new'], + } + + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'removeShippingAddressId', + addressId: 'remove', + }, + { + action: 'removeShippingAddressId', + addressId: 'remove2', + }, + { + action: 'addShippingAddressId', + addressId: 'new', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomerGroup` action with key', () => { + const before: DeepPartial = {} + const now: DeepPartial = { + customerGroup: { + typeId: 'customer-group', + }, + } + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomerGroup', + customerGroup: now.customerGroup, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = customerSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = customerSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setStores` action', () => { + const before: DeepPartial = { + stores: [ + { + typeId: 'store', + key: 'canada', + }, + ], + } + const now: DeepPartial = { + stores: [ + { + typeId: 'store', + key: 'canada', + }, + { + typeId: 'store', + key: 'usa', + }, + ], + } + const actual = customerSync.buildActions(now, before) + expect(actual).toEqual([ + { + action: 'setStores', + stores: [ + { + typeId: 'store', + key: 'canada', + }, + { + typeId: 'store', + key: 'usa', + }, + ], + }, + ]) + }) + + test('should build `setKey` action', () => { + const before = { + key: 'key-before', + } + + const now = { + key: 'key-now', + } + const actual = customerSync.buildActions(now, before) + expect(actual).toEqual([ + { + action: 'setKey', + key: 'key-now', + }, + ]) + }) + + test('should build not throw error for empty array', () => { + const before: DeepPartial = { + stores: [ + { + typeId: 'store', + key: 'canada', + }, + { + typeId: 'store', + key: 'usa', + }, + ], + } + + const now = {} + + const actual = customerSync.buildActions(now, before) + expect(actual).toEqual([ + { + action: 'setStores', + stores: [], + }, + ]) + }) + + test('should build setAuthenticationMode sync action', () => { + let before + let now + let actual + let expected + + before = { + authenticationMode: 'Password', + } + now = { + authenticationMode: 'ExternalAuth', + } + + actual = customerSync.buildActions(now, before) + + expected = [ + { + action: 'setAuthenticationMode', + authMode: now.authenticationMode, + }, + ] + expect(actual).toEqual(expected) + + before = { + authenticationMode: 'ExternalAuth', + } + now = { + authenticationMode: 'Password', + password: 'abc123', + } + + actual = customerSync.buildActions(now, before) + expected = [ + { + action: 'setAuthenticationMode', + authMode: now.authenticationMode, + password: now.password, + }, + ] + + expect(actual).toEqual(expected) + + before = {} + now = { + authenticationMode: 'ExternalAuth', + } + + actual = customerSync.buildActions(now, before) + expected = [ + { + action: 'setAuthenticationMode', + authMode: now.authenticationMode, + }, + ] + expect(actual).toEqual(expected) + + before = { + authenticationMode: 'ExternalAuth', + } + now = {} + + actual = customerSync.buildActions(now, before) + + expected = [] + expect(actual).toEqual(expected) + + before = { + authenticationMode: 'ExternalAuth', + } + now = { + authenticationMode: '', + } + + expect(() => { + customerSync.buildActions(now, before) + }).toThrow('Invalid Authentication Mode') + }) + + test('should throw error if password not specified while setting authenticationMode to password', () => { + const before = { + authenticationMode: 'ExternalAuth', + } + const now = { + authenticationMode: 'Password', + } + + expect(() => { + customerSync.buildActions(now, before) + }).toThrow('Cannot set to Password authentication mode without password') + }) + + test('should throw error if user specifies invalid authentication mode', () => { + const before = { + authenticationMode: 'ExternalAuth', + } + const now = { + authenticationMode: 'xyz', + } + + expect(() => { + customerSync.buildActions(now, before) + }).toThrow('Invalid Authentication Mode') + }) +}) diff --git a/packages/sync-actions/test/discount-codes-sync.spec.ts b/packages/sync-actions/test/discount-codes-sync.spec.ts new file mode 100644 index 000000000..a63d5c9a3 --- /dev/null +++ b/packages/sync-actions/test/discount-codes-sync.spec.ts @@ -0,0 +1,414 @@ +import { actionGroups, createSyncDiscountCodes } from '../src/discount-codes' +import { baseActionsList } from '../src/discount-codes-actions' +import { DeepPartial } from '../src/types/update-actions' +import { DiscountCodeDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeIsActive` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeIsActive', key: 'isActive' }]) + ) + }) + + test('should contain `setName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setName', key: 'name' }]) + ) + }) + + test('should contain `setDescription` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setDescription', + key: 'description', + }, + ]) + ) + }) + + test('should contain `setKey` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setKey', + key: 'key', + }, + ]) + ) + }) + + test('should contain `setMaxApplications` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setMaxApplications', + key: 'maxApplications', + }, + ]) + ) + }) + + test('should contain `setMaxApplicationsPerCustomer` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setMaxApplicationsPerCustomer', + key: 'maxApplicationsPerCustomer', + }, + ]) + ) + }) + + test('should contain `changeCartDiscounts` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeCartDiscounts', + key: 'cartDiscounts', + }, + ]) + ) + }) + + test('should contain `setValidFrom` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidFrom', key: 'validFrom' }]) + ) + }) + + test('should contain `setValidUntil` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidUntil', key: 'validUntil' }]) + ) + }) + + test('should contain `changeGroups` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeGroups', key: 'groups' }]) + ) + }) + }) +}) + +describe('Actions', () => { + let discountCodesSync = createSyncDiscountCodes() + beforeEach(() => { + discountCodesSync = createSyncDiscountCodes() + }) + + test('should build `changeIsActive` action', () => { + const before = { isActive: false } + const now = { isActive: true } + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'changeIsActive', + isActive: true, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setName` action', () => { + const before = { + name: { en: 'previous-en-name', de: 'previous-de-name' }, + } + const now = { + name: { en: 'current-en-name', de: 'current-de-name' }, + } + + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setName', + name: { en: 'current-en-name', de: 'current-de-name' }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: { en: 'old-en-description', de: 'old-de-description' }, + } + const now = { + description: { en: 'new-en-description', de: 'new-de-description' }, + } + + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: { en: 'new-en-description', de: 'new-de-description' }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setKey` action', () => { + const before = { + key: 'old-key', + } + const now = { + key: 'new-key', + } + + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setKey', + key: 'new-key', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCartPredicate` action', () => { + const before = { cartPredicate: 'old-cart-predicate' } + const now = { cartPredicate: 'new-cart-predicate' } + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setCartPredicate', + cartPredicate: 'new-cart-predicate', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setMaxApplications` action', () => { + const before = { maxApplications: 5 } + const now = { maxApplications: 10 } + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setMaxApplications', + maxApplications: 10, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setMaxApplicationsPerCustomer` action', () => { + const before = { maxApplicationsPerCustomer: 1 } + const now = { maxApplicationsPerCustomer: 3 } + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setMaxApplicationsPerCustomer', + maxApplicationsPerCustomer: 3, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeCartDiscounts` action', () => { + const before: DeepPartial = { + cartDiscounts: [ + { + typeId: 'cart-discount', + id: 'previous-cart-discount-id', + }, + { + typeId: 'cart-discount', + id: 'another-previous-cart-discount-id', + }, + ], + } + const now: DeepPartial = { + cartDiscounts: [ + { + typeId: 'cart-discount', + id: 'previous-cart-discount-id', + }, + { + typeId: 'cart-discount', + id: 'new-cart-discount-id-1', + }, + { + typeId: 'cart-discount', + id: 'new-cart-discount-id-2', + }, + { + typeId: 'cart-discount', + id: 'another-new-cart-discount-id-2', + }, + ], + } + + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'changeCartDiscounts', + cartDiscounts: [ + { + typeId: 'cart-discount', + id: 'previous-cart-discount-id', + }, + { + typeId: 'cart-discount', + id: 'new-cart-discount-id-1', + }, + { + typeId: 'cart-discount', + id: 'new-cart-discount-id-2', + }, + { + typeId: 'cart-discount', + id: 'another-new-cart-discount-id-2', + }, + ], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build the `setValidFrom` action', () => { + const before = { + validFrom: 'date1', + } + + const now = { + validFrom: 'date2', + } + + const expected = [ + { + action: 'setValidFrom', + validFrom: 'date2', + }, + ] + const actual = discountCodesSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidUntil` action', () => { + const before = { + validUntil: 'date1', + } + + const now = { + validUntil: 'date2', + } + + const expected = [ + { + action: 'setValidUntil', + validUntil: 'date2', + }, + ] + const actual = discountCodesSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidFromAndUntil` action when both `validFrom` and `validUntil` exist', () => { + const before = { + validFrom: 'date-1-From', + validUntil: 'date-1-Until', + } + + const now = { + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + } + + const expected = [ + { + action: 'setValidFromAndUntil', + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + }, + ] + const actual = discountCodesSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeGroups` action', () => { + const before = { + groups: ['A'], + } + + const now = { + groups: ['A', 'B'], + } + + const expected = [ + { + action: 'changeGroups', + groups: ['A', 'B'], + }, + ] + const actual = discountCodesSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = discountCodesSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = discountCodesSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/inventory-sync.spec.ts b/packages/sync-actions/test/inventory-sync.spec.ts new file mode 100644 index 000000000..f42784d14 --- /dev/null +++ b/packages/sync-actions/test/inventory-sync.spec.ts @@ -0,0 +1,111 @@ +import { actionGroups, createSyncInventories } from '../src/inventories' +import { baseActionsList, referenceActionsList } from '../src/inventory-actions' +import { InventoryEntryDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'references']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { + action: 'changeQuantity', + key: 'quantityOnStock', + actionKey: 'quantity', + }, + { action: 'setRestockableInDays', key: 'restockableInDays' }, + { action: 'setExpectedDelivery', key: 'expectedDelivery' }, + ]) + }) + + test('correctly define reference actions list', () => { + expect(referenceActionsList).toEqual([ + { action: 'setSupplyChannel', key: 'supplyChannel' }, + ]) + }) +}) + +describe('Actions', () => { + let inventorySync = createSyncInventories() + beforeEach(() => { + inventorySync = createSyncInventories() + }) + + test('should build `changeQuantity` action', () => { + const before = { + quantityOnStock: 1, + } + const now = { + quantityOnStock: 2, + } + + const actual = inventorySync.buildActions(now, before) + const expected = [{ action: 'changeQuantity', quantity: 2 }] + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: Partial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: Partial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = inventorySync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: Partial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: Partial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = inventorySync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/order-sync.spec.ts b/packages/sync-actions/test/order-sync.spec.ts new file mode 100644 index 000000000..bc3c482ca --- /dev/null +++ b/packages/sync-actions/test/order-sync.spec.ts @@ -0,0 +1,908 @@ +import { performance } from 'perf_hooks' +import { actionGroups, createSyncOrders } from '../src/orders' +import { baseActionsList } from '../src/order-actions' +import { DeepPartial } from '../src/types/update-actions' +import { Order } from '@commercetools/platform-sdk' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'deliveries']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeOrderState', key: 'orderState' }, + { action: 'changePaymentState', key: 'paymentState' }, + { action: 'changeShipmentState', key: 'shipmentState' }, + ]) + }) +}) + +describe('Actions', () => { + let orderSync = createSyncOrders() + beforeEach(() => { + orderSync = createSyncOrders() + }) + + describe('base', () => { + test('should build *state actions', () => { + const before: DeepPartial = { + orderState: 'Open', + paymentState: 'Pending', + shipmentState: 'Ready', + } + const now = { + orderState: 'Complete', + paymentState: 'Paid', + shipmentState: 'Shipped', + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { action: 'changeOrderState', orderState: 'Complete' }, + { action: 'changePaymentState', paymentState: 'Paid' }, + { action: 'changeShipmentState', shipmentState: 'Shipped' }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('deliveries', () => { + test('should build `addDelivery` action', () => { + const before: DeepPartial = { + shippingInfo: { + deliveries: [], + }, + } + const now: DeepPartial = { + shippingInfo: { + deliveries: [ + { + items: [ + { id: 'li-1', quantity: 1 }, + { id: 'li-2', quantity: 2 }, + ], + parcels: [ + { + measurements: { + heightInMillimeter: 1, + lengthInMillimeter: 1, + widthInMillimeter: 1, + weightInGram: 1, + }, + trackingData: { + trackingId: '111', + }, + }, + ], + }, + ], + }, + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'addDelivery', + items: now.shippingInfo.deliveries[0].items, + parcels: now.shippingInfo.deliveries[0].parcels, + }, + ] + expect(actual).toEqual(expected) + }) + test('should build `setDeliveryItems` action', () => { + const before = { + shippingInfo: { + deliveries: [ + { + id: 'delivery-1', + items: [ + { id: 'li-1', qty: 1 }, + { id: 'li-2', qty: 2 }, + ], + parcels: [], + }, + ], + }, + } + const now = { + shippingInfo: { + deliveries: [ + { + id: 'delivery-1', + items: [{ id: 'li-2', qty: 2 }], + parcels: [], + }, + ], + }, + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setDeliveryItems', + items: now.shippingInfo.deliveries[0].items, + deliveryId: now.shippingInfo.deliveries[0].id, + deliveryKey: undefined, + }, + ] + expect(actual).toEqual(expected) + }) + test('should build multiple `setDeliveryItems` action', () => { + const before = { + shippingInfo: { + deliveries: [ + { + id: 'delivery-1', + items: [ + { id: 'li-1', qty: 1 }, + { id: 'li-2', qty: 2 }, + ], + parcels: [], + }, + { + id: 'delivery-2', + items: [], + parcels: [], + }, + ], + }, + } + const now = { + shippingInfo: { + deliveries: [ + { + id: 'delivery-1', + items: [{ id: 'li-2', qty: 2 }], + parcels: [], + }, + { + id: 'delivery-2', + items: [ + { id: 'li-1', qty: 1 }, + { id: 'li-2', qty: 2 }, + ], + parcels: [], + }, + ], + }, + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setDeliveryItems', + items: now.shippingInfo.deliveries[0].items, + deliveryId: now.shippingInfo.deliveries[0].id, + deliveryKey: undefined, + }, + { + action: 'setDeliveryItems', + items: now.shippingInfo.deliveries[1].items, + deliveryId: now.shippingInfo.deliveries[1].id, + deliveryKey: undefined, + }, + ] + expect(actual).toEqual(expected) + }) + describe('performance test', () => { + it('should be performant for large arrays', () => { + const before = { + shippingInfo: { + deliveries: Array(100) + .fill(null) + .map((_a, index) => ({ + parcels: [], + items: Array(50) + .fill(null) + .map((_b, index2) => { + return { + id: `id-${index}-${index2}`, + qty: 1, + } + }), + })), + }, + } + const now = { + shippingInfo: { + deliveries: Array(100) + .fill(null) + .map((_a, index) => ({ + parcels: [], + items: Array(50) + .fill(null) + .map((_b, index2) => { + return { + id: `id-${index}-${index2}`, + qty: 2, + } + }), + })), + }, + } + + const start = performance.now() + orderSync.buildActions(now, before) + const end = performance.now() + + expect(end - start).toBeLessThan(500) + }) + }) + }) + + describe('parcels', () => { + test('should add `parcel` action', () => { + const before = { + shippingInfo: { + deliveries: [ + { + id: 'id-1', + parcels: [ + { + id: 'unique-id-1', + measurements: { + heightInMillimeter: 20, + lengthInMillimeter: 40, + widthInMillimeter: 5, + weightInGram: 10, + }, + trackingData: { + trackingId: 'tracking-id-1', + }, + }, + ], + }, + ], + }, + } + + const now = { + shippingInfo: { + deliveries: [ + { + id: 'id-1', + parcels: [ + { + id: 'unique-id-1', + measurements: { + heightInMillimeter: 20, + lengthInMillimeter: 40, + widthInMillimeter: 5, + weightInGram: 10, + }, + trackingData: { + trackingId: 'tracking-id-1', + }, + }, + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { + trackingId: 'tracking-id-2', + }, + }, + ], + }, + ], + }, + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'addParcelToDelivery', + deliveryId: now.shippingInfo.deliveries[0].id, + measurements: now.shippingInfo.deliveries[0].parcels[1].measurements, + trackingData: now.shippingInfo.deliveries[0].parcels[1].trackingData, + }, + ] + + expect(actual).toEqual(expected) + }) + + test('should create remove `parcel` action', () => { + const before: DeepPartial = { + shippingInfo: { + deliveries: [ + { + id: 'id-1', + parcels: [ + { + id: 'unique-id-1', + measurements: { + heightInMillimeter: 20, + lengthInMillimeter: 40, + widthInMillimeter: 5, + weightInGram: 10, + }, + trackingData: { + trackingId: 'tracking-id-1', + }, + }, + ], + }, + ], + }, + } + + const now: DeepPartial = { + shippingInfo: { + deliveries: [ + { + id: 'id-1', + parcels: [], + }, + ], + }, + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'removeParcelFromDelivery', + parcelId: before.shippingInfo.deliveries[0].parcels[0].id, + }, + ] + + expect(actual).toEqual(expected) + }) + }) + + describe('returnInfo', () => { + test('should not build `returnInfo` action if items are not set', () => { + const before: DeepPartial = { + returnInfo: [], + } + + const now: DeepPartial = { + returnInfo: [ + { + returnTrackingId: 'tracking-id-1', + returnDate: '21-04-30T09:21:15.003Z', + }, + ], + } + + const actual = orderSync.buildActions(now, before) + const expected = [] + expect(actual).toEqual(expected) + }) + + test('should add `returnInfo` action', () => { + const before = { + returnInfo: [], + } + + const now: DeepPartial = { + returnInfo: [ + { + returnTrackingId: 'tracking-id-1', + items: [ + { + id: 'test-1', + type: 'LineItemReturnItem', + quantity: 1, + lineItemId: '1', + shipmentState: 'Advised', + paymentState: 'Initial', + }, + { + id: 'test-2', + type: 'LineItemReturnItem', + quantity: 1, + lineItemId: '1', + shipmentState: 'Advised', + paymentState: 'Initial', + }, + ], + }, + { + returnTrackingId: 'tracking-id-2', + items: [ + { + id: 'test-3', + type: 'LineItemReturnItem', + quantity: 2, + lineItemId: '2', + shipmentState: 'Advised', + paymentState: 'Initial', + }, + ], + }, + ], + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'addReturnInfo', + returnTrackingId: now.returnInfo[0].returnTrackingId, + items: now.returnInfo[0].items, + }, + { + action: 'addReturnInfo', + returnTrackingId: now.returnInfo[1].returnTrackingId, + items: now.returnInfo[1].items, + }, + ] + + expect(actual).toEqual(expected) + }) + + test('should build `setReturnShipmentState` action', () => { + const before = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-2', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const now = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + // This have changed + shipmentState: 'backInStock', + paymentState: 'initial', + }, + { + id: 'test-2', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setReturnShipmentState', + returnItemId: now.returnInfo[0].items[0].id, + shipmentState: now.returnInfo[0].items[0].shipmentState, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setReturnPaymentState` action', () => { + const before = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-2', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const now = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-2', + shipmentState: 'returned', + // This have changed + paymentState: 'refunded', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[0].items[1].id, + paymentState: now.returnInfo[0].items[1].paymentState, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setReturnShipmentState` and `setReturnPaymentState` action', () => { + const before = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-2', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const now = { + returnInfo: [ + { + returnTrackingId: 'touched-item', + items: [ + { + id: 'test-1', + // This have changed + shipmentState: 'backInStock', + // This have changed + paymentState: 'refunded', + }, + { + id: 'test-2', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + { + returnTrackingId: 'not-touched-item', + items: [ + { + id: 'test-3', + shipmentState: 'returned', + paymentState: 'initial', + }, + { + id: 'test-4', + shipmentState: 'returned', + paymentState: 'initial', + }, + ], + }, + ], + } + + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setReturnShipmentState', + returnItemId: now.returnInfo[0].items[0].id, + shipmentState: now.returnInfo[0].items[0].shipmentState, + }, + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[0].items[0].id, + paymentState: now.returnInfo[0].items[0].paymentState, + }, + ] + expect(actual).toEqual(expected) + }) + describe('when all items have changed its `paymentState`', () => { + test('should build `returnInfoPaymentState` action', () => { + const before = { + returnInfo: [ + { + items: [ + { + id: 'id1', + shipmentState: 'Returned', + paymentState: 'Initial', + }, + ], + }, + { + items: [ + { + id: 'id2', + shipmentState: 'Returned', + paymentState: 'Initial', + }, + { + id: 'id3', + shipmentState: 'Returned', + paymentState: 'Initial', + }, + { + id: 'id4', + shipmentState: 'Returned', + paymentState: 'Initial', + }, + { + id: 'id5', + shipmentState: 'Returned', + paymentState: 'Initial', + }, + ], + returnDate: '2022-10-24T00:00:00.000Z', + }, + ], + } + const now = { + returnInfo: [ + { + items: [ + { + id: 'id1', + shipmentState: 'Returned', + paymentState: 'NotRefunded', + }, + ], + }, + { + items: [ + { + id: 'id2', + shipmentState: 'Returned', + paymentState: 'Refunded', + }, + { + id: 'id3', + shipmentState: 'Returned', + paymentState: 'Refunded', + }, + { + id: 'id4', + shipmentState: 'Returned', + paymentState: 'Refunded', + }, + { + id: 'id5', + shipmentState: 'Returned', + paymentState: 'Refunded', + }, + ], + returnDate: '2022-10-24T00:00:00.000Z', + }, + ], + } + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[0].items[0].id, + paymentState: now.returnInfo[0].items[0].paymentState, + }, + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[1].items[0].id, + paymentState: now.returnInfo[1].items[0].paymentState, + }, + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[1].items[1].id, + paymentState: now.returnInfo[1].items[1].paymentState, + }, + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[1].items[2].id, + paymentState: now.returnInfo[1].items[2].paymentState, + }, + { + action: 'setReturnPaymentState', + returnItemId: now.returnInfo[1].items[3].id, + paymentState: now.returnInfo[1].items[3].paymentState, + }, + ] + expect(actual).toEqual(expected) + }) + describe('performance test', () => { + it('should be performant for large arrays', () => { + const before = { + returnInfo: Array(100) + .fill(null) + .map((_a, index) => ({ + items: Array(50) + .fill(null) + .map((_b, index2) => { + return { + id: `id-${index}-${index2}`, + shipmentState: 'Returned', + paymentState: 'Initial', + } + }), + })), + } + const now = { + returnInfo: Array(100) + .fill(null) + .map((_a, index) => ({ + items: Array(50) + .fill(null) + .map((_b, index2) => { + return { + id: `id-${index}-${index2}`, + shipmentState: 'Returned', + paymentState: 'Refunded', + } + }), + })), + } + + const start = performance.now() + orderSync.buildActions(now, before) + const end = performance.now() + + expect(end - start).toBeLessThan(500) + }) + }) + }) + }) +}) + +describe('custom fields', () => { + let orderSync = createSyncOrders() + beforeEach(() => { + orderSync = createSyncOrders() + }) + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = orderSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = orderSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/price-sync.spec.ts b/packages/sync-actions/test/price-sync.spec.ts new file mode 100644 index 000000000..29308aa2f --- /dev/null +++ b/packages/sync-actions/test/price-sync.spec.ts @@ -0,0 +1,930 @@ +import { actionGroups, createSyncStandalonePrices } from '../src/prices' +import { DeepPartial } from '../src/types/update-actions' +import { StandalonePrice } from '@commercetools/platform-sdk' + +const pricesSync = createSyncStandalonePrices() + +const dateNow = new Date().toString() +const twoWeeksFromNow = new Date(Date.now() + 12096e5).toString() + +/* eslint-disable max-len */ +describe('price actions', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + test('should not build actions if prices are not set', () => { + const before: DeepPartial = {} + const now: DeepPartial = {} + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should not build actions if now price is not set', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + } + const now: DeepPartial = {} + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should not build actions if there is no change', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + discounted: { + value: { centAmount: 4000, currencyCode: 'EGP' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + } + + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + discounted: { + value: { centAmount: 4000, currencyCode: 'EGP' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + } + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + describe('changeValue', () => { + test('should generate changeValue action', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + } + + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 5678, + fractionDigits: 2, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeValue', + value: { + centAmount: 5678, + currencyCode: 'EUR', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ]) + }) + }) + + describe('setDiscountedPrice', () => { + test('should build `setDiscountedPrice` action for newly discounted', () => { + const before: DeepPartial = { + id: '1010', + value: { currencyCode: 'EGP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + } + + const now: DeepPartial = { + id: '1010', + value: { currencyCode: 'EGP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + discounted: { + value: { centAmount: 4000, currencyCode: 'EGP' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setDiscountedPrice', + discounted: { + value: { centAmount: 4000, currencyCode: 'EGP' }, + discount: { + typeId: 'product-discount', + id: 'pd1', + }, + }, + }, + ]) + }) + + test('should build `setDiscountedPrice` action for removed discounted', () => { + const before: DeepPartial = { + id: '1010', + value: { currencyCode: 'EGP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + discounted: { + value: { centAmount: 4000, currencyCode: 'EGP' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + } + + const now: DeepPartial = { + id: '1010', + value: { currencyCode: 'EGP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + // TODO: check this + discounted: null, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setDiscountedPrice', + discounted: undefined, + }, + ]) + }) + + test('should build `setDiscountedPrice` action for changed value centAmount', () => { + const before: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + discounted: { + value: { centAmount: 4000, currencyCode: 'EUR' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + } + + const now: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + discounted: { + value: { centAmount: 3000, currencyCode: 'EUR' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setDiscountedPrice', + discounted: { + value: { centAmount: 3000, currencyCode: 'EUR' }, + discount: { + typeId: 'product-discount', + id: 'pd1', + }, + }, + }, + ]) + }) + }) + + describe('setPriceTiers', () => { + test('should build `setPriceTiers` action if price tier are set', () => { + const before: DeepPartial = {} + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + ], + } + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeValue', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + { + action: 'setPriceTiers', + tiers: [ + { + minimumQuantity: 5, + value: { + centAmount: 1900, + currencyCode: 'EUR', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + }, + ]) + }) + + test('should build `setPriceTiers` action for price tier change', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + ], + } + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 900, + fractionDigits: 2, + }, + }, + ], + } + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setPriceTiers', + tiers: [ + { + minimumQuantity: 5, + value: { + centAmount: 900, + currencyCode: 'EUR', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + }, + ]) + }) + + test('should build `setPriceTiers` action for removed price tier', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + { + minimumQuantity: 25, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 2900, + fractionDigits: 2, + }, + }, + ], + } + + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + ], + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setPriceTiers', + tiers: [ + { + minimumQuantity: 5, + value: { + centAmount: 1900, + currencyCode: 'EUR', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + }, + ]) + }) + + test('should build `setPriceTiers` action when removed all price tier', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + { + minimumQuantity: 25, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 2900, + fractionDigits: 2, + }, + }, + ], + } + + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: null, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setPriceTiers', + tiers: undefined, + }, + ]) + }) + + test('should not build `setPriceTiers` action when price tiers on now and then are equal', () => { + const before: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + { + minimumQuantity: 25, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 2900, + fractionDigits: 2, + }, + }, + ], + } + + const now: DeepPartial = { + id: '9fe6610f', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + tiers: [ + { + minimumQuantity: 5, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + }, + { + minimumQuantity: 25, + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 2900, + fractionDigits: 2, + }, + }, + ], + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + }) + + describe('setKey', () => { + test('should build `setKey` action', () => { + const key = 'test-key' + + const before: DeepPartial = { + id: '1010', + key: undefined, + } + + const now: DeepPartial = { + id: '1010', + key, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setKey', + key, + }, + ]) + }) + }) + + describe('setValidFrom', () => { + test('should build `setValidFrom` action', () => { + const before: DeepPartial = { + id: '1010', + validFrom: dateNow, + validUntil: dateNow, + } + + const now: DeepPartial = { + id: '1010', + validFrom: twoWeeksFromNow, + validUntil: dateNow, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setValidFrom', + validFrom: twoWeeksFromNow, + }, + ]) + }) + }) + + describe('setValidUntil', () => { + test('should build `setValidUntil` action', () => { + const before: DeepPartial = { + id: '1010', + validFrom: dateNow, + validUntil: dateNow, + } + + const now: DeepPartial = { + id: '1010', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setValidUntil', + validUntil: twoWeeksFromNow, + }, + ]) + }) + }) + + describe('setValidFromAndUntil', () => { + it('should build `setValidFromAndUntil` action', () => { + const before: DeepPartial = { + id: '1010', + validFrom: dateNow, + validUntil: dateNow, + } + + const now: DeepPartial = { + id: '1010', + validFrom: twoWeeksFromNow, + validUntil: twoWeeksFromNow, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setValidFromAndUntil', + validFrom: twoWeeksFromNow, + validUntil: twoWeeksFromNow, + }, + ]) + }) + }) + + describe('changeActive', () => { + test('should build `changeActive` action', () => { + const before: DeepPartial = { + id: '1010', + active: false, + } + + const now: DeepPartial = { + id: '1010', + active: true, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeActive', + active: true, + }, + ]) + }) + }) + + describe('setCustomType', () => { + test('should build `setCustomType` action without fields', () => { + const before: DeepPartial = { + id: '888', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + } + + const now: DeepPartial = { + id: '888', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomType', + type: { + id: '5678', + typeId: 'type', + }, + }, + ]) + }) + + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + id: '999', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + } + + const now: DeepPartial = { + // set price custom type and field + id: '999', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomType', + type: { + id: '5678', + typeId: 'type', + }, + fields: { + source: 'shop', + }, + }, + ]) + }) + + test('should build `setCustomType` action which delete custom type', () => { + const before: DeepPartial = { + id: '1111', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + } + + const now: DeepPartial = { + // remove price custom field and type + id: '1111', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomType', + }, + ]) + }) + }) + + describe('setCustomField', () => { + test('should generate `setCustomField` actions', () => { + const before: DeepPartial = { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + id: '9fe6610f', + country: 'DE', + custom: { + type: { + typeId: 'type', + id: '218d8068', + }, + fields: { + touchpoints: ['value'], + }, + }, + } + + const now: DeepPartial = { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + id: '9fe6610f', + country: 'DE', + custom: { + type: { + typeId: 'type', + id: '218d8068', + }, + fields: { + published: false, + }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomField', + name: 'touchpoints', + value: undefined, + }, + { + action: 'setCustomField', + name: 'published', + value: false, + }, + ]) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + } + + const now: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'random', + }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomField', + name: 'source', + value: 'random', + }, + ]) + }) + + test('should build three `setCustomField` action', () => { + const before: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + source2: 'shop2', + source3: 'shop3', + source4: 'shop4', + }, + }, + } + + const now: DeepPartial = { + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'random', + source2: 'random2', + source3: 'random3', + source4: 'shop4', + }, + }, + } + + const actions = pricesSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setCustomField', + name: 'source', + value: 'random', + }, + { + action: 'setCustomField', + name: 'source2', + value: 'random2', + }, + { + action: 'setCustomField', + name: 'source3', + value: 'random3', + }, + ]) + }) + }) +}) diff --git a/packages/sync-actions/test/product-discounts-sync.spec.ts b/packages/sync-actions/test/product-discounts-sync.spec.ts new file mode 100644 index 000000000..1572e5ca4 --- /dev/null +++ b/packages/sync-actions/test/product-discounts-sync.spec.ts @@ -0,0 +1,289 @@ +import { + actionGroups, + createSyncProductDiscounts, +} from '../src/product-discounts' +import { baseActionsList } from '../src/product-discounts-actions' +import { DeepPartial } from '../src/types/update-actions' +import { ProductDiscountDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base']) + }) + + describe('Exports', () => { + test('should contain `changeIsActive` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeIsActive', key: 'isActive' }]) + ) + }) + + test('should contain `changeName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeName', key: 'name' }]) + ) + }) + + test('should contain `changePredicate` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changePredicate', + key: 'predicate', + }, + ]) + ) + }) + + test('should contain `changeSortOrder` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeSortOrder', key: 'sortOrder' }, + ]) + ) + }) + + test('should contain `changeValue` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeValue', key: 'value' }]) + ) + }) + + test('should contain `setDescription` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setDescription', + key: 'description', + }, + ]) + ) + }) + + test('should contain `setValidFrom` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidFrom', key: 'validFrom' }]) + ) + }) + + test('should contain `setValidUntil` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidUntil', key: 'validUntil' }]) + ) + }) + + test('should contain `setKey` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setKey', key: 'key' }]) + ) + }) + }) +}) + +describe('Actions', () => { + let productDiscountsSync = createSyncProductDiscounts() + beforeEach(() => { + productDiscountsSync = createSyncProductDiscounts() + }) + + test('should build the `changeIsActive` action', () => { + const before = { + isActive: false, + } + + const now = { + isActive: true, + } + + const expected = [ + { + action: 'changeIsActive', + isActive: true, + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build "changeName" action', () => { + const before = { + name: { en: 'en-name-before', de: 'de-name-before' }, + } + + const now = { + name: { en: 'en-name-now', de: 'de-name-now' }, + } + + const expected = [ + { + action: 'changeName', + name: { en: 'en-name-now', de: 'de-name-now' }, + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changePredicate` action', () => { + const before = { + predicate: '1=1', + } + + const now = { + predicate: 'sku="test-sku"', + } + + const expected = [ + { + action: 'changePredicate', + predicate: 'sku="test-sku"', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeSortOrder` action', () => { + const before = { + sortOrder: '0.1', + } + + const now = { + sortOrder: '0.2', + } + + const expected = [ + { + action: 'changeSortOrder', + sortOrder: '0.2', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `changeValue` action', () => { + const before: DeepPartial = { + value: { + type: 'relative', + permyriad: 100, + }, + } + + const now: DeepPartial = { + value: { + type: 'relative', + permyriad: 200, + }, + } + + const expected = [ + { + action: 'changeValue', + value: { + type: 'relative', + permyriad: 200, + }, + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + test('should build the `setDescription` action', () => { + const before = { + description: { + en: 'en-description-before', + de: 'de-description-before', + }, + } + const now = { + description: { en: 'en-description-now', de: 'de-description-now' }, + } + const expected = [ + { + action: 'setDescription', + description: { en: 'en-description-now', de: 'de-description-now' }, + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidFrom` action', () => { + const before = { + validFrom: 'date1', + } + + const now = { + validFrom: 'date2', + } + + const expected = [ + { + action: 'setValidFrom', + validFrom: 'date2', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setValidUntil` action', () => { + const before = { + validUntil: 'date1', + } + + const now = { + validUntil: 'date2', + } + + const expected = [ + { + action: 'setValidUntil', + validUntil: 'date2', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + test('should build the `setValidFromAndUntil` action when both `validFrom` and `validUntil` exist', () => { + const before = { + validFrom: 'date-1-From', + validUntil: 'date-1-Until', + } + + const now = { + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + } + + const expected = [ + { + action: 'setValidFromAndUntil', + validFrom: 'date-2-From', + validUntil: 'date-2-Until', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) + + test('should build the `setKey` action', () => { + const before = { + key: 'key-before', + } + + const now = { + key: 'key-now', + } + + const expected = [ + { + action: 'setKey', + key: 'key-now', + }, + ] + const actual = productDiscountsSync.buildActions(now, before) + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/product-selections-sync.spec.ts b/packages/sync-actions/test/product-selections-sync.spec.ts new file mode 100644 index 000000000..ee554923b --- /dev/null +++ b/packages/sync-actions/test/product-selections-sync.spec.ts @@ -0,0 +1,117 @@ +import { baseActionsList } from '../src/product-selections-actions' +import { + actionGroups, + createSyncProductSelections, +} from '../src/product-selections' +import { DeepPartial } from '../src/types/update-actions' +import { ProductSelectionDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + ]) + }) +}) + +describe('Actions', () => { + let productSelectionsSync = createSyncProductSelections() + beforeEach(() => { + productSelectionsSync = createSyncProductSelections() + }) + + test('should build `changeName` action', () => { + const before = { + name: { en: 'Algeria' }, + } + const now = { + name: { en: 'Algeria', de: 'Algerian' }, + } + + const actual = productSelectionsSync.buildActions(now, before) + const expected = [{ action: 'changeName', name: now.name }] + expect(actual).toEqual(expected) + }) + + test('should build `setKey` action', () => { + const before = { + key: '', + } + const now = { + key: 'new-key', + } + + const actual = productSelectionsSync.buildActions(now, before) + const expected = [{ action: 'setKey', key: now.key }] + expect(actual).toEqual(expected) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = productSelectionsSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = productSelectionsSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/product-sync-base.spec.ts b/packages/sync-actions/test/product-sync-base.spec.ts new file mode 100644 index 000000000..a68471b71 --- /dev/null +++ b/packages/sync-actions/test/product-sync-base.spec.ts @@ -0,0 +1,381 @@ +import clone from '../src/utils/clone' +import { actionGroups, createSyncProducts, ProductSync } from '../src/products' +import { + baseActionsList, + metaActionsList, + referenceActionsList, +} from '../src/product-actions' +import { DeepPartial } from '../src/types/update-actions' +import { ProductDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual([ + 'base', + 'meta', + 'references', + 'prices', + 'pricesCustom', + 'attributes', + 'images', + 'variants', + 'categories', + 'categoryOrderHints', + ]) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'changeSlug', key: 'slug' }, + { action: 'setDescription', key: 'description' }, + { action: 'setSearchKeywords', key: 'searchKeywords' }, + { action: 'setKey', key: 'key' }, + { action: 'setPriceMode', key: 'priceMode' }, + ]) + }) + + test('correctly define meta actions list', () => { + expect(metaActionsList).toEqual([ + { action: 'setMetaTitle', key: 'metaTitle' }, + { action: 'setMetaDescription', key: 'metaDescription' }, + { action: 'setMetaKeywords', key: 'metaKeywords' }, + ]) + }) + + test('correctly define reference actions list', () => { + expect(referenceActionsList).toEqual([ + { action: 'setTaxCategory', key: 'taxCategory' }, + { action: 'transitionState', key: 'state' }, + ]) + }) +}) + +describe('Actions', () => { + let productsSync = createSyncProducts() + beforeEach(() => { + productsSync = createSyncProducts() + }) + + test('should ensure given objects are not mutated', () => { + const before = { + name: { en: 'Car', de: 'Auto' }, + key: 'unique-key', + masterVariant: { + id: 1, + sku: '001', + attributes: [{ name: 'a1', value: 1 }], + }, + variants: [ + { id: 2, sku: '002', attributes: [{ name: 'a2', value: 2 }] }, + { id: 3, sku: '003', attributes: [{ name: 'a3', value: 3 }] }, + ], + } + const now = { + name: { en: 'Sport car' }, + key: 'unique-key-2', + masterVariant: { + id: 1, + sku: '100', + attributes: [{ name: 'a1', value: 100 }], + }, + variants: [ + { id: 2, sku: '200', attributes: [{ name: 'a2', value: 200 }] }, + { id: 3, sku: '300', attributes: [{ name: 'a3', value: 300 }] }, + ], + } + productsSync.buildActions(now, before) + expect(before).toEqual(clone(before)) + expect(now).toEqual(clone(now)) + }) + + test('should build `setKey` action', () => { + const before = { key: 'unique-key-1' } + const now: Partial = { key: 'unique-key-2' } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([{ action: 'setKey', ...now }]) + }) + + test('should build `changeName` action', () => { + const before = { name: { en: 'Car', de: 'Auto' } } + const now = { name: { en: 'Sport car' } } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([{ action: 'changeName', ...now }]) + }) + + test('should build action with `staged` flag as false', () => { + const before = { name: { en: 'Car', de: 'Auto' } } + const now = { name: { en: 'Sport car' }, publish: true } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'changeName', name: { en: 'Sport car' }, staged: false }, + ]) + }) + + test('should build action with `staged` flag as false when `staged` is set to false', () => { + const before = { name: { en: 'Car', de: 'Auto' } } + const now = { name: { en: 'New sport car' }, staged: false } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'changeName', name: { en: 'New sport car' }, staged: false }, + ]) + }) + + test('should build action without `staged` flag', () => { + const before = { name: { en: 'Car', de: 'Auto' } } + const now = { name: { en: 'Sport car' }, publish: false } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'changeName', name: { en: 'Sport car' } }, + ]) + }) + + test('should build `setSearchKeywords` action', () => { + /* eslint-disable max-len */ + const before: DeepPartial = { + searchKeywords: { + en: [ + { text: 'Multi tool' }, + { + text: 'Swiss Army Knife', + suggestTokenizer: { type: 'whitespace' }, + }, + ], + de: [ + { + text: 'Schweizer Messer', + suggestTokenizer: { + type: 'custom', + inputs: ['schweizer messer', 'offiziersmesser', 'sackmesser'], + }, + }, + ], + }, + } + const now: DeepPartial = { + searchKeywords: { + en: [ + { + text: 'Swiss Army Knife', + suggestTokenizer: { type: 'whitespace' }, + }, + ], + de: [ + { + text: 'Schweizer Messer', + suggestTokenizer: { + type: 'custom', + inputs: [ + 'schweizer messer', + 'offiziersmesser', + 'sackmesser', + 'messer', + ], + }, + }, + ], + it: [{ text: 'Coltello svizzero' }], + }, + } + /* eslint-enable max-len */ + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([{ action: 'setSearchKeywords', ...now }]) + }) + + test('should build no actions if searchKeywords did not change', () => { + /* eslint-disable max-len */ + const before: DeepPartial = { + name: { en: 'Car', de: 'Auto' }, + searchKeywords: { + en: [ + { text: 'Multi tool' }, + { + text: 'Swiss Army Knife', + suggestTokenizer: { type: 'whitespace' }, + }, + ], + de: [ + { + text: 'Schweizer Messer', + suggestTokenizer: { + type: 'custom', + inputs: ['schweizer messer', 'offiziersmesser', 'sackmesser'], + }, + }, + ], + }, + } + /* eslint-enable max-len */ + const actions = productsSync.buildActions(before, before) + expect(actions).toEqual([]) + }) + + test('should build `add/remove Category` actions', () => { + const before = { + categories: [ + { id: 'aebe844e-0616-420a-8397-a22c48d5e99f' }, + { id: '34cae6ad-5898-4f94-973b-ae9ceb7464ce' }, + ], + } + const now = { + categories: [ + { id: 'aebe844e-0616-420a-8397-a22c48d5e99f' }, + { id: '4f278964-48c0-4f2c-8b61-09310d1de60a' }, + { id: 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b' }, + ], + } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'removeFromCategory', + category: { id: '34cae6ad-5898-4f94-973b-ae9ceb7464ce' }, + }, + { + action: 'addToCategory', + category: { id: '4f278964-48c0-4f2c-8b61-09310d1de60a' }, + }, + { + action: 'addToCategory', + category: { id: 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b' }, + }, + ]) + }) + + test('should add/remove category and categoryOrderHints', () => { + const before = { + categories: [ + { id: '123e844e-0616-420a-8397-a22c48d5e99f' }, + { id: 'aebe844e-0616-420a-8397-a22c48d5e99f' }, + { id: '34cae6ad-5898-4f94-973b-ae9ceb7464ce' }, + ], + categoryOrderHints: { + '123e844e-0616-420a-8397-a22c48d5e99f': '0.1', // will be preserved + 'aebe844e-0616-420a-8397-a22c48d5e99f': '0.2', // will be changed to 0.5 + '34cae6ad-5898-4f94-973b-ae9ceb7464ce': '0.5', // will be removed + }, + } + + const now = { + categories: [ + { id: '123e844e-0616-420a-8397-a22c48d5e99f' }, + { id: 'aebe844e-0616-420a-8397-a22c48d5e99f' }, + { id: 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b' }, + ], + categoryOrderHints: { + '123e844e-0616-420a-8397-a22c48d5e99f': '0.1', + 'aebe844e-0616-420a-8397-a22c48d5e99f': '0.5', + 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b': '0.999', + }, + } + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'removeFromCategory', + category: { id: '34cae6ad-5898-4f94-973b-ae9ceb7464ce' }, + }, + { + action: 'addToCategory', + category: { id: 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b' }, + }, + { + action: 'setCategoryOrderHint', + categoryId: 'aebe844e-0616-420a-8397-a22c48d5e99f', + orderHint: '0.5', + }, + { + action: 'setCategoryOrderHint', + categoryId: '34cae6ad-5898-4f94-973b-ae9ceb7464ce', + }, + { + action: 'setCategoryOrderHint', + categoryId: 'cca7a250-d8cf-4b8a-9d47-60fcc093b86b', + orderHint: '0.999', + }, + ]) + }) + + test('shouldnt generate any categoryOrderHints actions', () => { + const before = { + categoryOrderHints: {}, + } + + const now = {} + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([]) + }) + + test('shouldnt generate any searchKeywords actions', () => { + const before = { + searchKeywords: {}, + } + + const now = {} + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([]) + }) + + test('should build base actions for long diff text', () => { + const longText = ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Nunc ultricies fringilla tortor eu egestas. + Praesent rhoncus molestie libero, eu tempor sapien placerat id. + Donec commodo nunc sed nulla scelerisque, eu pulvinar augue egestas. + Donec at leo dolor. Cras at molestie arcu. + Sed non fringilla quam, sit amet ultricies massa. + Donec luctus tempus erat, ut suscipit elit varius nec. + Mauris dolor enim, aliquet sed nulla et, dignissim lobortis augue. + Proin pharetra magna eu neque semper tristique sed. + ` + + /* eslint-disable max-len */ + const before = { + name: { + en: longText, + }, + slug: { + en: longText, + }, + description: { + en: longText, + }, + } + const now = { + name: { + en: `Hello, ${longText}`, + }, + slug: { + en: `Hello, ${longText}`, + }, + description: { + en: `Hello, ${longText}`, + }, + } + /* eslint-enable max-len */ + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeName', + name: now.name, + }, + { + action: 'changeSlug', + slug: now.slug, + }, + { + action: 'setDescription', + description: now.description, + }, + ]) + }) +}) diff --git a/packages/sync-actions/test/product-sync-images.spec.ts b/packages/sync-actions/test/product-sync-images.spec.ts new file mode 100644 index 000000000..47f4a4866 --- /dev/null +++ b/packages/sync-actions/test/product-sync-images.spec.ts @@ -0,0 +1,484 @@ +import { DeepPartial } from '../src/types/update-actions' +import { createSyncProducts, ProductSync } from '../src/products' + +/* eslint-disable max-len */ +describe('Actions', () => { + let productsSync = createSyncProducts() + beforeEach(() => { + productsSync = createSyncProducts() + }) + + describe('with matching variant order', () => { + test('should build actions for images', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + images: [], + }, + variants: [ + { + id: 2, + images: [ + { + url: '//example.com/image2.png', + label: 'foo', + dimensions: { h: 1024, w: 768 }, + }, + ], + }, + { + id: 3, + images: [ + { + url: '//example.com/image3.png', + label: 'foo', + dimensions: { h: 1024, w: 768 }, + }, + { + url: '//example.com/image4.png', + dimensions: { h: 1024, w: 768 }, + }, + { + url: '//example.com/image5.png', + label: 'foo', + dimensions: { h: 1024, w: 768 }, + }, + ], + }, + { + id: 4, + images: [ + // Order is important! + { + url: '//example.com/old-remove.png', + dimensions: { h: 400, w: 600 }, + }, + { + url: '//example.com/old-keep.png', + dimensions: { h: 608, w: 1000 }, + }, + ], + }, + ], + } + + const now = { + id: '123', + masterVariant: { + id: 1, + images: [ + // new image + { url: 'http://cat.com', label: 'A cat' }, + ], + }, + variants: [ + { + id: 2, + images: [ + // no changes + { + url: '//example.com/image2.png', + label: 'foo', + dimensions: { h: 1024, w: 768 }, + }, + ], + }, + { + id: 3, + images: [ + // label added + { + url: '//example.com/image4.png', + label: 'ADDED', + dimensions: { h: 400, w: 300 }, + }, + // label changed + { + url: '//example.com/image3.png', + label: 'CHANGED', + dimensions: { h: 1024, w: 768 }, + }, + // url changed (new image) + { + url: '//example.com/CHANGED.jpg', + label: 'foo', + dimensions: { h: 400, w: 300 }, + }, + ], + }, + // images removed + { + id: 4, + images: [ + { + url: '//example.com/old-keep.png', + dimensions: { h: 608, w: 1000 }, + }, + ], + }, + ], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'addExternalImage', + variantId: 1, + image: { url: 'http://cat.com', label: 'A cat' }, + }, + { + action: 'setImageLabel', + variantId: 3, + imageUrl: '//example.com/image4.png', + label: 'ADDED', + }, + { + action: 'setImageLabel', + variantId: 3, + imageUrl: '//example.com/image3.png', + label: 'CHANGED', + }, + { + action: 'addExternalImage', + variantId: 3, + image: { + url: '//example.com/CHANGED.jpg', + label: 'foo', + dimensions: { h: 400, w: 300 }, + }, + }, + { + action: 'moveImageToPosition', + variantId: 3, + imageUrl: '//example.com/image4.png', + position: 0, + }, + { + action: 'removeImage', + variantId: 3, + imageUrl: '//example.com/image5.png', + }, + { + action: 'removeImage', + variantId: 4, + imageUrl: '//example.com/old-remove.png', + }, + ]) + }) + }) + + describe('with non-matching variant order', () => { + test('should detect image movement', () => { + const before = { + key: 'foo-key', + published: true, + hasStagedChanges: true, + masterVariant: { + assets: [], + images: [], + prices: [], + sku: 'third-variant', + id: 3, + }, + variants: [ + { + assets: [], + images: [], + prices: [], + id: 4, + }, + { + assets: [], + images: [], + prices: [], + sku: 'testing-animation', + id: 5, + }, + { + assets: [], + images: [ + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/Screen+Shot+2017-04--LOx1OrZZ.png', + dimensions: { + w: 1456, + h: 1078, + }, + }, + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/cactus-with-surfboar-BmOeVZEZ.jpg', + label: 'cactus', + dimensions: { + w: 602, + h: 600, + }, + }, + ], + sku: '89978FRU', + id: 1, + }, + { + assets: [], + images: [], + prices: [], + sku: 'vid6', + id: 10, + }, + { + availability: { + isOnStock: true, + availableQuantity: 5678, + }, + assets: [], + images: [], + key: 'test', + sku: 'secondary-variant', + id: 2, + }, + ], + } + + const now = { + masterVariant: { + id: 1, + sku: '89978FRU', + images: [ + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/cactus-with-surfboar-BmOeVZEZ.jpg', + label: 'cactus', + dimensions: { + w: 602, + h: 600, + }, + }, + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/Screen+Shot+2017-04--LOx1OrZZ.png', + dimensions: { + w: 1456, + h: 1078, + }, + }, + ], + assets: [], + }, + variants: [ + { + id: 2, + sku: 'secondary-variant', + key: 'test', + prices: [], + images: [], + assets: [], + availability: { + isOnStock: true, + availableQuantity: 5678, + }, + }, + { + id: 3, + sku: 'third-variant', + prices: [], + images: [], + assets: [], + }, + { + id: 4, + prices: [], + images: [], + assets: [], + }, + { + id: 5, + sku: 'testing-animation', + prices: [], + images: [], + assets: [], + }, + ], + hasStagedChanges: true, + published: true, + key: 'foo-key', + } + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeMasterVariant', + variantId: 1, + }, + { + action: 'removeVariant', + id: 10, + }, + { + action: 'moveImageToPosition', + variantId: 1, + imageUrl: + 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/cactus-with-surfboar-BmOeVZEZ.jpg', + position: 0, + }, + ]) + }) + + test('should build actions for image removal', () => { + const before = { + key: 'foo-key', + published: true, + hasStagedChanges: true, + masterVariant: { + assets: [], + images: [], + prices: [], + sku: 'third-variant', + id: 3, + }, + variants: [ + { + assets: [], + images: [], + prices: [], + id: 4, + }, + { + assets: [], + images: [], + prices: [], + sku: 'testing-animation', + id: 5, + }, + { + assets: [], + prices: [], + images: [ + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/Screen+Shot+2017-04--LOx1OrZZ.png', + dimensions: { + w: 1456, + h: 1078, + }, + }, + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/cactus-with-surfboar-BmOeVZEZ.jpg', + label: 'cactus', + dimensions: { + w: 602, + h: 600, + }, + }, + ], + sku: '89978FRU', + id: 1, + }, + { + assets: [], + images: [], + prices: [], + sku: 'vid6', + id: 10, + }, + { + availability: { + isOnStock: true, + availableQuantity: 5678, + }, + assets: [], + prices: [], + images: [], + key: 'test', + sku: 'secondary-variant', + id: 2, + }, + ], + } + + const now = { + masterVariant: { + id: 1, + sku: '89978FRU', + prices: [], + images: [ + { + url: 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/cactus-with-surfboar-BmOeVZEZ.jpg', + label: 'cactus', + dimensions: { + w: 602, + h: 600, + }, + }, + ], + assets: [], + }, + variants: [ + { + id: 2, + sku: 'secondary-variant', + key: 'test', + prices: [], + images: [], + assets: [], + availability: { + isOnStock: true, + availableQuantity: 5678, + }, + }, + { + id: 3, + sku: 'third-variant', + prices: [], + images: [], + assets: [], + }, + { + id: 4, + prices: [], + images: [], + assets: [], + }, + { + id: 5, + sku: 'testing-animation', + prices: [], + images: [], + assets: [], + }, + ], + hasStagedChanges: true, + published: true, + key: 'foo-key', + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changeMasterVariant', + variantId: 1, + }, + { + action: 'removeVariant', + id: 10, + }, + { + action: 'removeImage', + imageUrl: + 'https://95bc80c3c245100a18cc-04fc5bec7ec901344d7cbd57f9a2fab3.ssl.cf3.rackcdn.com/Screen+Shot+2017-04--LOx1OrZZ.png', + variantId: 1, + }, + ]) + }) + }) + + describe('without images', () => { + test('should not build actions if images are not set', () => { + const before: DeepPartial = { + masterVariant: { images: [] }, + variants: [], + } + const now: DeepPartial = { + masterVariant: {}, + variants: [], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + }) +}) diff --git a/packages/sync-actions/test/product-sync-prices.spec.ts b/packages/sync-actions/test/product-sync-prices.spec.ts new file mode 100644 index 000000000..cf54386ea --- /dev/null +++ b/packages/sync-actions/test/product-sync-prices.spec.ts @@ -0,0 +1,1109 @@ +import { DeepPartial } from '../src/types/update-actions' +import { createSyncProducts, ProductSync } from '../src/products' +import { DiscountedPriceDraft } from '@commercetools/platform-sdk/src' + +/* eslint-disable max-len */ +describe('Actions', () => { + let productsSync = createSyncProducts() + beforeEach(() => { + productsSync = createSyncProducts() + }) + + describe('with `priceID`', () => { + const discounted: DiscountedPriceDraft = { + value: { centAmount: 4000, currencyCode: 'EUR' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + } + const validFrom = new Date().toISOString() + + const before: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + prices: [ + { + id: '111', + value: { currencyCode: 'EUR', centAmount: 1000 }, + discounted, + }, + ], + }, + variants: [ + { + id: 3, + prices: [], + }, + { + id: 2, + prices: [ + { + id: '222', + value: { currencyCode: 'EUR', centAmount: 1000 }, + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + discounted, + }, + ], + }, + { + id: 4, + prices: [ + { + id: '223', + value: { currencyCode: 'USD', centAmount: 1200 }, + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + discounted, + }, + { + id: '444', + value: { currencyCode: 'EUR', centAmount: 1000 }, + country: 'DE', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + discounted, + }, + ], + }, + ], + } + + const now: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + prices: [ + // Changed + { + id: '111', + value: { currencyCode: 'EUR', centAmount: 2000 }, + country: 'US', + discounted, + }, + ], + }, + variants: [ + { + id: 2, + // Removed + prices: [], + }, + { + id: 3, + prices: [ + // New + { + value: { currencyCode: 'USD', centAmount: 5000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + validFrom, + }, + ], + }, + { + id: 4, + prices: [ + // No change + { + id: '444', + value: { currencyCode: 'EUR', centAmount: 1000 }, + country: 'DE', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + }, + { + id: '223', + value: { currencyCode: 'USD', centAmount: 1200 }, + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + discounted, + }, + ], + }, + ], + } + + test('should build actions for prices', () => { + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changePrice', + priceId: '111', + price: { + id: '111', + value: { currencyCode: 'EUR', centAmount: 2000 }, + country: 'US', + }, + }, + { action: 'removePrice', priceId: '222' }, + { + action: 'addPrice', + variantId: 3, + price: { + value: { currencyCode: 'USD', centAmount: 5000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + validFrom, + }, + }, + ]) + }) + + test('should build actions for prices with discounted when enableDiscounted is set to true', () => { + const actions = productsSync.buildActions(now, before, { + enableDiscounted: true, + }) + expect(actions).toEqual([ + { + action: 'changePrice', + priceId: '111', + price: { + country: 'US', + id: '111', + value: { currencyCode: 'EUR', centAmount: 2000 }, + discounted: { + value: { centAmount: 4000, currencyCode: 'EUR' }, + discount: { typeId: 'product-discount', id: 'pd1' }, + }, + }, + }, + { + action: 'changePrice', + price: { + channel: { + id: 'ch1', + typeId: 'channel', + }, + country: 'DE', + customerGroup: { + id: 'cg1', + typeId: 'customer-group', + }, + id: '444', + value: { + centAmount: 1000, + currencyCode: 'EUR', + fractionDigits: undefined, + type: undefined, + }, + }, + priceId: '444', + }, + { action: 'removePrice', priceId: '222' }, + { + action: 'addPrice', + variantId: 3, + price: { + value: { currencyCode: 'USD', centAmount: 5000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + validFrom, + }, + }, + ]) + }) + + test('should not delete the discounted field from the original object', () => { + expect('discounted' in before.masterVariant.prices[0]).toBeTruthy() + expect('discounted' in now.masterVariant.prices[0]).toBeTruthy() + }) + }) + + test('should not build actions if prices are not set', () => { + const before = { + id: '123-abc', + masterVariant: { id: 1, prices: [] }, + variants: [], + } + const now = { + id: '456-def', + masterVariant: { id: 1, prices: [] }, + variants: [], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should generate PriceCustom actions before changePrice action', () => { + const before: DeepPartial = { + id: '123', + masterVariant: { + prices: [ + { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1900, + fractionDigits: 2, + }, + id: '9fe6610f', + country: 'DE', + custom: { + type: { + typeId: 'type', + id: '218d8068', + }, + fields: { + touchpoints: ['value'], + }, + }, + }, + ], + }, + } + + const now: DeepPartial = { + id: '123', + masterVariant: { + prices: [ + { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 5678, + fractionDigits: 2, + }, + id: '9fe6610f', + country: 'DE', + custom: { + type: { + typeId: 'type', + id: '218d8068', + }, + fields: { + published: false, + }, + }, + }, + ], + }, + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'setProductPriceCustomField', + name: 'touchpoints', + priceId: '9fe6610f', + value: undefined, + }, + { + action: 'setProductPriceCustomField', + name: 'published', + priceId: '9fe6610f', + value: false, + }, + { + action: 'changePrice', + price: { + country: 'DE', + custom: { + fields: { + published: false, + }, + type: { + id: '218d8068', + typeId: 'type', + }, + }, + id: '9fe6610f', + value: { + centAmount: 5678, + currencyCode: 'EUR', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + priceId: '9fe6610f', + }, + ]) + }) + + describe('without `priceID`', () => { + let actions + const dateNow = new Date().toISOString() + const twoWeeksFromNow = new Date(Date.now() + 12096e5).toISOString() // two weeks from now + const threeWeeksFromNow = new Date(Date.now() + 12096e5 * 1.5).toISOString() + + const before: DeepPartial = { + id: '123-abc', + masterVariant: { + id: 1, + prices: [ + { + // change + id: '111', + value: { currencyCode: 'EUR', centAmount: 3000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + }, + { + // change + id: '333', + value: { currencyCode: 'SEK', centAmount: 10000 }, + country: 'US', + channel: { typeId: 'channel', id: 'ch1' }, + }, + { + // keep + id: '444', + value: { currencyCode: 'SEK', centAmount: 25000 }, + country: 'SE', + }, + { + // remove + id: '666', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: twoWeeksFromNow, + validUntil: threeWeeksFromNow, + }, + { + // change + id: '777', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + { + // set price custom type + id: '888', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + { + // set price custom type and field + id: '999', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + { + // change price custom field + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + { + // remove price custom field + id: '1111', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + { + // action `changePrice` should contian custom object + id: '2222', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + ], + }, + } + let now: DeepPartial = { + id: '456-def', + masterVariant: { + id: 1, + prices: [ + { + // change + value: { currencyCode: 'EUR', centAmount: 4000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + }, + { + // change + value: { currencyCode: 'SEK', centAmount: 15000 }, + country: 'US', + channel: { typeId: 'channel', id: 'ch1' }, + }, + { + // change + value: { currencyCode: 'GBP', centAmount: 10000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + { + // keep + value: { currencyCode: 'SEK', centAmount: 25000 }, + country: 'SE', + }, + { + // add + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'US', + validFrom: twoWeeksFromNow, + validUntil: threeWeeksFromNow, + }, + { + // set price custom type + id: '888', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + }, + }, + { + // set price custom type and field + id: '999', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + { + // change price custom field + id: '1010', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'random', + }, + }, + }, + { + // remove price custom field and type + id: '1111', + value: { currencyCode: 'GBP', centAmount: 1000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + { + // action `changePrice` should contian custom object + id: '2222', + value: { currencyCode: 'GBP', centAmount: 2000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + ], + }, + } + + beforeEach(() => { + now = { + masterVariant: { + prices: now.masterVariant.prices.sort(() => Math.random() - 0.5), + ...now.masterVariant, + }, + ...now, + } + actions = productsSync.buildActions(now, before) + }) + + test('should build five update actions', () => { + expect(actions).toHaveLength(14) + }) + + test('should build `changePrice` actions', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'changePrice', + priceId: '111', + price: { + id: '111', + value: { currencyCode: 'EUR', centAmount: 4000 }, + country: 'US', + customerGroup: { typeId: 'customer-group', id: 'cg1' }, + channel: { typeId: 'channel', id: 'ch1' }, + }, + }, + { + action: 'changePrice', + priceId: '333', + price: { + id: '333', + value: { currencyCode: 'SEK', centAmount: 15000 }, + country: 'US', + channel: { typeId: 'channel', id: 'ch1' }, + }, + }, + { + action: 'changePrice', + priceId: '777', + price: { + id: '777', + value: { currencyCode: 'GBP', centAmount: 10000 }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + }, + }, + ]) + ) + }) + + test('should build `removePrice` action', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'removePrice', + priceId: '666', + }, + ]) + ) + }) + + test('should build `addPrice` action', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'addPrice', + price: { + value: { + currencyCode: 'GBP', + centAmount: 1000, + }, + country: 'US', + validFrom: twoWeeksFromNow, + validUntil: threeWeeksFromNow, + }, + variantId: 1, + }, + ]) + ) + }) + + test('should build `changePrice` action without deleting `custom` prop', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'changePrice', + price: { + id: '2222', + value: { + currencyCode: 'GBP', + centAmount: 2000, + fractionDigits: undefined, + type: undefined, + }, + country: 'UK', + validFrom: dateNow, + validUntil: twoWeeksFromNow, + custom: { + type: { + typeId: 'type', + id: '5678', + }, + fields: { + source: 'shop', + }, + }, + }, + priceId: '2222', + }, + ]) + ) + }) + + test('should build `setProductPriceCustomType` action without fields', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'setProductPriceCustomType', + priceId: '888', + type: { + id: '5678', + typeId: 'type', + }, + }, + ]) + ) + }) + + test('should build `setProductPriceCustomType` action', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'setProductPriceCustomType', + priceId: '999', + type: { + id: '5678', + typeId: 'type', + }, + fields: { + source: 'shop', + }, + }, + ]) + ) + }) + + test('should build `setProductPriceCustomType` action which delete custom type', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'setProductPriceCustomType', + priceId: '1111', + }, + ]) + ) + }) + + test('should build `setProductPriceCustomField` action', () => { + expect(actions).toEqual( + expect.arrayContaining([ + { + action: 'setProductPriceCustomField', + name: 'source', + priceId: '1010', + value: 'random', + }, + ]) + ) + }) + + test('should remove a price without id', () => { + const oldProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + id: 'DE_PRICE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1111, + fractionDigits: 2, + }, + }, + { + country: 'LT', + id: 'LT_PRICE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 2222, + fractionDigits: 2, + }, + }, + { + country: 'IT', + id: 'IT_PRICE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 3333, + fractionDigits: 2, + }, + }, + ], + attributes: [], + }, + variants: [], + } + const newProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1111, + fractionDigits: 2, + }, + }, + { + country: 'IT', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 3333, + fractionDigits: 2, + }, + }, + ], + attributes: [], + }, + variants: [], + } + const updateActions = productsSync.buildActions(newProduct, oldProduct) + expect(updateActions).toHaveLength(1) + expect(updateActions).toEqual([ + { + action: 'removePrice', + priceId: 'LT_PRICE', + }, + ]) + }) + + test('should handle missing optional fields', () => { + const oldProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + id: 'DE_PRICE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1111, + fractionDigits: 2, + }, + }, + ], + attributes: [], + }, + variants: [], + } + const newProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + value: { + currencyCode: 'EUR', + centAmount: 1111, + // optional fields are missing here + }, + }, + ], + attributes: [], + }, + variants: [], + } + const updateActions = productsSync.buildActions(newProduct, oldProduct) + expect(updateActions).toHaveLength(0) + }) + + test('should sync when optional fields are different', () => { + const oldProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + id: 'DE_PRICE', + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 1111, + fractionDigits: 2, + }, + }, + ], + attributes: [], + }, + variants: [], + } + const newProduct: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + prices: [ + { + country: 'DE', + value: { + type: 'highPrecision', + currencyCode: 'EUR', + centAmount: 1111, + fractionDigits: 4, + }, + }, + ], + attributes: [], + }, + variants: [], + } + const updateActions = productsSync.buildActions(newProduct, oldProduct) + expect(updateActions).toHaveLength(1) + expect(updateActions).toEqual([ + { + action: 'changePrice', + price: { + country: 'DE', + id: 'DE_PRICE', + value: { + centAmount: 1111, + currencyCode: 'EUR', + fractionDigits: 4, + type: 'highPrecision', + }, + }, + priceId: 'DE_PRICE', + }, + ]) + }) + }) + + describe('without `country`', () => { + let actions + const before: DeepPartial = { + id: '81400c95-1de9-4431-9abd-a3eb8e0884d5', + name: { + de: 'abcd', + }, + description: { + de: 'abcd', + }, + categories: [], + categoryOrderHints: {}, + slug: { + de: 'abcd', + }, + masterVariant: { + id: 1, + sku: '1111111111111', + prices: [ + { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 8995, + fractionDigits: 2, + }, + id: '7960b455-ab04-4722-a8b2-431460d60012', + custom: { + type: { + typeId: 'type', + id: 'aada6bfb-2df1-4877-90f3-6efae0fbefa8', + }, + fields: { + promotionDiscountType: 'VerlaengerungHZArtikel', + promotionValidToExclusive: '2018-09-30T23:59:35.570Z', + promotionValidFromInclusive: '2018-09-24T00:00:35.570Z', + promotionRegularPrice: 9995, + }, + }, + }, + ], + }, + variants: [ + { + id: 2, + sku: '22222222222', + prices: [ + { + value: { + type: 'centPrecision', + currencyCode: 'EUR', + centAmount: 8995, + fractionDigits: 2, + }, + id: '38a9667a-976d-4bcc-8d2a-b04701e41f18', + custom: { + type: { + typeId: 'type', + id: 'aada6bfb-2df1-4877-90f3-6efae0fbefa8', + }, + fields: { + promotionDiscountType: 'VerlaengerungHZArtikel', + promotionValidToExclusive: '2018-09-30T23:59:35.570Z', + promotionValidFromInclusive: '2018-09-24T00:00:35.570Z', + promotionRegularPrice: 9995, + }, + }, + }, + ], + }, + ], + } + + const now: DeepPartial = { + id: '81400c95-1de9-4431-9abd-a3eb8e0884d5', + name: { + de: 'abcd', + }, + description: { + de: 'abcd', + }, + categories: [], + categoryOrderHints: {}, + slug: { + de: 'abcd', + }, + masterVariant: { + id: 1, + sku: '1111111111111', + prices: [ + { + value: { + centAmount: 6495, + currencyCode: 'EUR', + }, + custom: { + type: { + id: 'aada6bfb-2df1-4877-90f3-6efae0fbefa8', + }, + fields: { + promotionDiscountType: 'Tiefpreis', + promotionRegularPrice: 8995, + promotionValidToExclusive: 'SKU-1111111111111', + promotionValidFromInclusive: '2018-10-02T00:00:35.570Z', + }, + }, + }, + ], + }, + variants: [ + { + id: 2, + sku: '22222222222', + prices: [ + { + value: { + centAmount: 8995, + currencyCode: 'EUR', + }, + custom: { + type: { + id: 'aada6bfb-2df1-4877-90f3-6efae0fbefa8', + }, + fields: { + promotionDiscountType: 'Tiefpreis', + promotionRegularPrice: 9995, + promotionValidToExclusive: 'SKU-2222222222222', + promotionValidFromInclusive: '2018-10-02T00:00:35.570Z', + }, + }, + }, + ], + }, + ], + } + + beforeEach(() => { + actions = productsSync.buildActions(now, before) + }) + + test('should sync when optional fields are different', () => { + const actionNames = actions.map((action) => action.action) + + expect(actions).toHaveLength(2) + expect(actionNames).toEqual(['changePrice', 'changePrice']) + }) + }) + + describe('with read only prices', () => { + const before: Readonly> = { + id: '123', + masterVariant: { + id: 1, + prices: [ + { + id: '111', + value: { currencyCode: 'EUR', centAmount: 1000 }, + }, + ], + }, + } + + const now: Readonly> = { + id: '123', + masterVariant: { + id: 1, + prices: [ + { + id: '111', + value: { currencyCode: 'EUR', centAmount: 2000 }, + country: 'US', + }, + ], + }, + } + + test('should build actions for prices', () => { + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { + action: 'changePrice', + priceId: '111', + price: { + id: '111', + value: { currencyCode: 'EUR', centAmount: 2000 }, + country: 'US', + }, + }, + ]) + }) + }) +}) diff --git a/packages/sync-actions/test/product-sync-variants.spec.ts b/packages/sync-actions/test/product-sync-variants.spec.ts new file mode 100644 index 000000000..6b9acee51 --- /dev/null +++ b/packages/sync-actions/test/product-sync-variants.spec.ts @@ -0,0 +1,1812 @@ +import { DeepPartial } from '../src/types/update-actions' +import { createSyncProducts, ProductSync } from '../src/products' +import { Asset } from '@commercetools/platform-sdk/src' + +/* eslint-disable max-len */ +describe('Actions', () => { + let productsSync = createSyncProducts() + beforeEach(() => { + productsSync = createSyncProducts() + }) + + test('should build attribute actions', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'uid', value: '20063672' }, + { name: 'length', value: 160 }, + { name: 'wide', value: 85 }, + { name: 'bulkygoods', value: { label: 'Ja', key: 'YES' } }, + { name: 'ean', value: '20063672' }, + ], + }, + variants: [ + { id: 3, attributes: [] }, + { + id: 2, + attributes: [ + { name: 'uid', value: '20063672' }, + { name: 'length', value: 160 }, + { name: 'wide', value: 85 }, + { name: 'bulkygoods', value: { label: 'Ja', key: 'YES' } }, + { name: 'ean', value: '20063672' }, + ], + }, + { + id: 4, + attributes: [ + { name: 'uid', value: '1234567' }, + { name: 'length', value: 123 }, + { name: 'bulkygoods', value: { label: 'Si', key: 'SI' } }, + ], + }, + ], + } + + const now = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'uid', value: '20063675' }, // changed + { name: 'length', value: 160 }, + { name: 'wide', value: 10 }, // changed + { name: 'bulkygoods', value: 'NO' }, // changed + { name: 'ean', value: '20063672' }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + { name: 'uid', value: '20055572' }, // changed + { name: 'length', value: 333 }, // changed + { name: 'wide', value: 33 }, // changed + { name: 'bulkygoods', value: 'YES' }, // changed + { name: 'ean', value: '20063672' }, + ], + }, + { + id: 3, + attributes: [ + // new + { name: 'uid', value: '00001' }, + { name: 'length', value: 500 }, + { name: 'bulkygoods', value: 'SI' }, + ], + }, + { id: 4, attributes: [] }, // removed + ], + } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'setAttribute', variantId: 1, name: 'uid', value: '20063675' }, + { action: 'setAttribute', variantId: 1, name: 'wide', value: 10 }, + { action: 'setAttribute', variantId: 1, name: 'bulkygoods', value: 'NO' }, + + { action: 'setAttribute', variantId: 2, name: 'uid', value: '20055572' }, + { action: 'setAttribute', variantId: 2, name: 'length', value: 333 }, + { action: 'setAttribute', variantId: 2, name: 'wide', value: 33 }, + { + action: 'setAttribute', + variantId: 2, + name: 'bulkygoods', + value: 'YES', + }, + + { action: 'setAttribute', variantId: 3, name: 'uid', value: '00001' }, + { action: 'setAttribute', variantId: 3, name: 'length', value: 500 }, + { action: 'setAttribute', variantId: 3, name: 'bulkygoods', value: 'SI' }, + + { action: 'setAttribute', variantId: 4, name: 'uid', value: undefined }, + { + action: 'setAttribute', + variantId: 4, + name: 'length', + value: undefined, + }, + { + action: 'setAttribute', + variantId: 4, + name: 'bulkygoods', + value: undefined, + }, + ]) + }) + + test('should handle long text values performantly', () => { + const longText = 'a'.repeat(10_000) + const longText2 = 'b'.repeat(10_000) + const before = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'color', value: longText }, + { name: 'size', value: longText }, + { name: 'weight', value: longText }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + { name: 'color', value: longText }, + { name: 'size', value: longText }, + { name: 'weight', value: longText }, + ], + }, + ], + } + + const now = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'color', value: longText2 }, + { name: 'size', value: longText2 }, + { name: 'weight', value: longText2 }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + { name: 'color', value: longText2 }, + { name: 'size', value: longText2 }, + { name: 'weight', value: longText2 }, + ], + }, + ], + } + + const startTime = Date.now() + const actions = productsSync.buildActions(now, before) + + // Should take less than 100ms. + expect(Date.now() - startTime).toBeLessThan(100) + expect(actions).toEqual([ + { action: 'setAttribute', variantId: 1, name: 'color', value: longText2 }, + { action: 'setAttribute', variantId: 1, name: 'size', value: longText2 }, + { + action: 'setAttribute', + variantId: 1, + name: 'weight', + value: longText2, + }, + { action: 'setAttribute', variantId: 2, name: 'color', value: longText2 }, + { action: 'setAttribute', variantId: 2, name: 'size', value: longText2 }, + { + action: 'setAttribute', + variantId: 2, + name: 'weight', + value: longText2, + }, + ]) + }) + + test('should build SameForAll attribute actions', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'color', value: 'red' }, + { name: 'size', value: 'M' }, + { name: 'weigth', value: '1' }, + ], + }, + variants: [ + { id: 3, attributes: [] }, + { + id: 2, + attributes: [ + { name: 'color', value: 'red' }, + { name: 'size', value: 'M' }, + { name: 'weigth', value: '2' }, + ], + }, + ], + } + + const now = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + // new + { name: 'vendor', value: 'ferrari' }, + // changed + { name: 'color', value: 'yellow' }, + // removed + { name: 'size', value: undefined }, + // normal attribute + { name: 'weigth', value: '3' }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + // new + { name: 'vendor', value: 'ferrari' }, + // changed + { name: 'color', value: 'yellow' }, + // removed + { name: 'size', value: undefined }, + // normal attribute + { name: 'weigth', value: '4' }, + ], + }, + { id: 3, attributes: [] }, + ], + } + + const actions = productsSync.buildActions(now, before, { + sameForAllAttributeNames: ['vendor', 'color', 'size'], + }) + + expect(actions).toEqual([ + { action: 'setAttributeInAllVariants', name: 'vendor', value: 'ferrari' }, + { action: 'setAttributeInAllVariants', name: 'color', value: 'yellow' }, + { action: 'setAttributeInAllVariants', name: 'size', value: undefined }, + { action: 'setAttribute', variantId: 1, name: 'weigth', value: '3' }, + { action: 'setAttribute', variantId: 2, name: 'weigth', value: '4' }, + ]) + }) + + test('should build SameForAll attribute actions for a SET of object values', () => { + const before = { + masterVariant: { + attributes: [ + { + name: 'set-attribute-reference-type', + value: [ + { id: '123', referenceTypeId: 'reference-example' }, + { id: '234', referenceTypeId: 'reference-example' }, + ], + }, + ], + }, + } + + const now = { + masterVariant: { + attributes: [ + { + name: 'set-attribute-reference-type', + value: [ + { id: '444', referenceTypeId: 'reference-example' }, + // Test setting null, to test and ensure that objectHash + // takes this type into account when calculating a correct + // hash for array diff with object values + // github.com/benjamine/jsondiffpatch/blob/master/docs/arrays.md + null, + ], + }, + ], + }, + } + + const actions = productsSync.buildActions(now, before, { + sameForAllAttributeNames: ['set-attribute-reference-type'], + }) + + expect(actions).toEqual([ + { + action: 'setAttributeInAllVariants', + name: 'set-attribute-reference-type', + value: [ + { id: '444', referenceTypeId: 'reference-example' }, + // Since objectHash gives the diffpatcher an index, + // we expect a null to be given here + null, + ], + }, + ]) + }) + + test('should build `addVariant` action', () => { + const newVariant: any = { + key: 'ddd', + sku: 'ccc', + attributes: [{ name: 'color', value: 'red' }], + images: [{ url: 'http://foo.com', label: 'foo' }], + prices: [{ value: { centAmount: 300, currencyCode: 'USD' } }], + } + + const before = { + variants: [ + { + id: 2, + key: 'eee', + sku: 'aaa', + attributes: [{ name: 'color', value: 'green' }], + prices: [{ value: { centAmount: 100, currencyCode: 'EUR' } }], + }, + { + id: 3, + key: 'fff', + sku: 'bbb', + attributes: [{ name: 'color', value: 'yellow' }], + prices: [{ value: { centAmount: 200, currencyCode: 'GBP' } }], + }, + ], + } + const now = { variants: before.variants.slice(0, 1).concat(newVariant) } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'addVariant', ...newVariant }, + { action: 'removeVariant', id: 3 }, + ]) + }) + + test('should handle mapping actions for new variants without ids', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + key: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [ + { + id: 2, + sku: 'v2', + key: 'v2', + attributes: [{ name: 'foo', value: 'qux' }], + }, + { + id: 3, + sku: 'v3', + key: 'v3', + attributes: [{ name: 'foo', value: 'baz' }], + }, + ], + } + + const now = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + key: 'v2', + attributes: [{ name: 'foo', value: 'new value' }], + }, + variants: [ + { + id: 3, + sku: 'v4', + key: 'v4', + attributes: [{ name: 'foo', value: 'i dont care' }], + }, + { + id: 2, + sku: 'v2', + key: 'v2', + attributes: [{ name: 'foo', value: 'another value' }], + }, + { + sku: 'v3', + key: 'v3', + attributes: [{ name: 'foo', value: 'yet another' }], + }, + ], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { action: 'setProductVariantKey', key: 'v2', variantId: 1 }, + { action: 'setAttribute', variantId: 1, name: 'foo', value: 'new value' }, + { action: 'setSku', sku: 'v4', variantId: 3 }, + { action: 'setProductVariantKey', key: 'v4', variantId: 3 }, + { + action: 'setAttribute', + variantId: 3, + name: 'foo', + value: 'i dont care', + }, + { + action: 'setAttribute', + variantId: 2, + name: 'foo', + value: 'another value', + }, + { + action: 'addVariant', + sku: 'v3', + key: 'v3', + attributes: [{ name: 'foo', value: 'yet another' }], + }, + ]) + }) + + describe('without master variant in `now`', () => { + describe('with master variant in `before`', () => { + const before = { + id: '123', + version: 1, + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [], + } + + const now = { + id: '123', + // <-- no masterVariant + variants: [], + } + + test('should generate update action to remove variant', () => { + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([{ action: 'removeVariant', id: 1 }]) + }) + }) + + describe('with variants in `now`', () => { + const before = { + id: '123', + version: 1, + variants: [ + { + id: 2, + sku: 'v2', + key: 'v2', + attributes: [{ name: 'foo', value: 'qux' }], + }, + { + id: 3, + sku: 'v3', + key: 'v3', + attributes: [{ name: 'foo', value: 'baz' }], + }, + ], + } + + const now = { + id: '123', + variants: [ + // changed + { + id: 2, + sku: 'v2', + key: 'v2', + attributes: [{ name: 'foo', value: 'another value' }], + }, + // changed + { + id: 3, + sku: 'v3', + key: 'v3', + attributes: [{ name: 'foo', value: 'i dont care' }], + }, + // new + { + sku: 'v4', + key: 'v4', + attributes: [{ name: 'foo', value: 'yet another' }], + }, + ], + } + + test('should generate `addVariant` and `setAttribute` actions', () => { + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setAttribute', + variantId: 2, + name: 'foo', + value: 'another value', + }, + { + action: 'setAttribute', + variantId: 3, + name: 'foo', + value: 'i dont care', + }, + { + action: 'addVariant', + sku: 'v4', + key: 'v4', + attributes: [{ name: 'foo', value: 'yet another' }], + }, + ]) + }) + }) + + describe('when changing master variant', () => { + describe('when moving master variant to variants', () => { + const before = { + id: '123', + version: 1, + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [], + } + + const now = { + id: '123', + version: 1, + masterVariant: { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [ + { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + ], + } + + test('should generate `changeMasterVariant` and `addVariant` action', () => { + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'addVariant', + attributes: [{ name: 'foo', value: 'bar' }], + id: 2, + sku: 'v1', + }, + { action: 'changeMasterVariant', variantId: 2 }, + ]) + }) + }) + + describe('when adding new master variant (without moving)', () => { + const before = { + id: '123', + version: 1, + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [], + } + + const now = { + id: '123', + version: 1, + masterVariant: { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [], + } + + test('should generate `changeMasterVariant`, `addVariant` and `removeVariant` action', () => { + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'addVariant', + attributes: [{ name: 'foo', value: 'bar' }], + id: 2, + sku: 'v1', + }, + { action: 'changeMasterVariant', variantId: 2 }, + { action: 'removeVariant', id: 1 }, + ]) + }) + }) + + describe('with existing variant in `now` and `before`', () => { + describe('without changes to attributes', () => { + const before = { + id: '123', + version: 1, + masterVariant: { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [ + { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo-2', value: 'bar-2' }], + }, + ], + } + + const now = { + id: '123', + version: 1, + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo-2', value: 'bar-2' }], + }, + variants: [ + { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + ], + } + + test('should generate `changeMasterVariant` action', () => { + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { action: 'changeMasterVariant', variantId: 1 }, + ]) + }) + }) + + describe('with changes to attributes', () => { + const before = { + id: '123', + version: 1, + masterVariant: { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + variants: [ + { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo-2', value: 'bar-2' }], + }, + ], + } + + const now = { + id: '123', + version: 1, + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo-2', value: 'bar-3' }], + }, + variants: [ + { + id: 2, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + ], + } + + test('should generate `changeMasterVariant` and `setAttribute` actions', () => { + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setAttribute', + name: 'foo-2', + value: 'bar-3', + variantId: 1, + }, + { action: 'changeMasterVariant', variantId: 1 }, + ]) + }) + }) + }) + }) + }) + + test('should handle unsetting the sku of a variant', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + sku: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + } + + const now = { + id: '123', + masterVariant: { + id: 1, + sku: '', + attributes: [{ name: 'foo', value: 'bar' }], + }, + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([{ action: 'setSku', sku: null, variantId: 1 }]) + }) + + test('should handle unsetting the key of a variant', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + key: 'v1', + attributes: [{ name: 'foo', value: 'bar' }], + }, + } + + const now = { + id: '123', + masterVariant: { + id: 1, + key: '', + attributes: [{ name: 'foo', value: 'bar' }], + }, + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { action: 'setProductVariantKey', key: null, variantId: 1 }, + ]) + }) + + test('should build attribute actions for all types', () => { + const before = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'foo', value: 'bar' }, // text + { name: 'dog', value: { en: 'Dog', de: 'Hund', es: 'perro' } }, // ltext + { name: 'num', value: 50 }, // number + { name: 'count', value: { label: 'One', key: 'one' } }, // enum + { name: 'size', value: { label: { en: 'Medium' }, key: 'medium' } }, // lenum + { name: 'color', value: { label: { en: 'Color' }, key: 'red' } }, // lenum + { name: 'cost', value: { centAmount: 990, currencyCode: 'EUR' } }, // money + { name: 'reference', value: { typeId: 'product', id: '111' } }, // reference + { name: 'welcome', value: ['hello', 'world'] }, // set text + { + name: 'welcome2', + value: [ + { en: 'hello', it: 'ciao' }, + { en: 'world', it: 'mondo' }, + ], + }, // set ltext + { name: 'multicolor', value: ['red'] }, // set enum + { + name: 'multicolor2', + value: [{ key: 'red', label: { en: 'red', it: 'rosso' } }], + }, // set lenum + ], + }, + } + + const now = { + id: '123', + masterVariant: { + id: 1, + attributes: [ + { name: 'foo', value: 'qux' }, // text + { name: 'dog', value: { en: 'Doggy', it: 'Cane', es: 'perro' } }, // ltext + { name: 'num', value: 100 }, // number + { name: 'count', value: { label: 'Two', key: 'two' } }, // enum + { name: 'size', value: { label: { en: 'Small' }, key: 'small' } }, // lenum + { name: 'color', value: { label: { en: 'Blue' }, key: 'blue' } }, // lenum + { name: 'cost', value: { centAmount: 550, currencyCode: 'EUR' } }, // money + { name: 'reference', value: { typeId: 'category', id: '222' } }, // reference + { name: 'welcome', value: ['hello'] }, // set text + { name: 'welcome2', value: [{ en: 'hello', it: 'ciao' }] }, // set ltext + { name: 'multicolor', value: ['red', 'yellow'] }, // set enum + { + name: 'multicolor2', + value: [ + { key: 'red', label: { en: 'red', it: 'rosso' } }, + { key: 'yellow', label: { en: 'yellow', it: 'giallo' } }, + ], + }, // set lenum + { + name: 'listWithEmptyValues', + value: ['', '', null, { id: '123', typeId: 'products' }], + }, // set reference + ], + }, + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([ + { action: 'setAttribute', variantId: 1, name: 'foo', value: 'qux' }, + { + action: 'setAttribute', + variantId: 1, + name: 'dog', + value: { en: 'Doggy', it: 'Cane', de: undefined, es: 'perro' }, + }, + { action: 'setAttribute', variantId: 1, name: 'num', value: 100 }, + { action: 'setAttribute', variantId: 1, name: 'count', value: 'two' }, + { action: 'setAttribute', variantId: 1, name: 'size', value: 'small' }, + { action: 'setAttribute', variantId: 1, name: 'color', value: 'blue' }, + { + action: 'setAttribute', + variantId: 1, + name: 'cost', + value: { centAmount: 550, currencyCode: 'EUR' }, + }, + { + action: 'setAttribute', + variantId: 1, + name: 'reference', + value: { typeId: 'category', id: '222' }, + }, + { + action: 'setAttribute', + variantId: 1, + name: 'welcome', + value: ['hello'], + }, + { + action: 'setAttribute', + variantId: 1, + name: 'welcome2', + value: [{ en: 'hello', it: 'ciao' }], + }, + { + action: 'setAttribute', + variantId: 1, + name: 'multicolor', + value: ['red', 'yellow'], + }, + { + action: 'setAttribute', + variantId: 1, + name: 'multicolor2', + value: [ + { key: 'red', label: { en: 'red', it: 'rosso' } }, + { key: 'yellow', label: { en: 'yellow', it: 'giallo' } }, + ], + }, // set lenum + { + action: 'setAttribute', + variantId: 1, + name: 'listWithEmptyValues', + value: ['', '', null, { id: '123', typeId: 'products' }], + }, // set reference + ]) + }) + + test('should ignore set sku', () => { + // Case when sku is not set, and the new value is empty or null + const before: DeepPartial = { + masterVariant: {}, + variants: [{}], + } + + const now: DeepPartial = { + masterVariant: { + sku: '', + }, + variants: [{ sku: null }], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should ignore set key', () => { + // Case when key is not set, and the new value is empty or null + const before: DeepPartial = { + masterVariant: { + id: 1, + }, + variants: [{ id: 2 }], + } + + const now: DeepPartial = { + masterVariant: { + id: 1, + key: '', + }, + variants: [{ id: 2, key: null }], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should ignore set sku if the sku was and still is empty', () => { + // Case when sku is not set, and the new value is empty or null + const before: DeepPartial = { + masterVariant: { + id: 1, + sku: '', + }, + variants: [{ id: 2 }], + } + + const now: DeepPartial = { + masterVariant: { + id: 1, + sku: '', + }, + variants: [{ id: 2, sku: null }], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should ignore set key if the key was and still is empty', () => { + // Case when key is not set, and the new value is empty or null + const before: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + key: '', + }, + variants: [{ id: 2 }], + } + + const now: DeepPartial = { + id: '123', + masterVariant: { + id: 1, + key: '', + }, + variants: [{ id: 2, key: null }], + } + + const actions = productsSync.buildActions(now, before) + expect(actions).toEqual([]) + }) + + test('should build `setAttribute` action text/ltext attributes with long text', () => { + const longText = ` + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Nunc ultricies fringilla tortor eu egestas. + Praesent rhoncus molestie libero, eu tempor sapien placerat id. + Donec commodo nunc sed nulla scelerisque, eu pulvinar augue egestas. + Donec at leo dolor. Cras at molestie arcu. + Sed non fringilla quam, sit amet ultricies massa. + Donec luctus tempus erat, ut suscipit elit varius nec. + Mauris dolor enim, aliquet sed nulla et, dignissim lobortis augue. + Proin pharetra magna eu neque semper tristique sed. + ` + + const newLongText = `Hello, ${longText}` + + /* eslint-disable max-len */ + const before = { + masterVariant: { + id: 1, + attributes: [ + { + name: 'text', + value: longText, + }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + { + name: 'ltext', + value: { + en: longText, + }, + }, + ], + }, + ], + } + const now = { + masterVariant: { + id: 1, + attributes: [ + { + name: 'text', + value: newLongText, + }, + ], + }, + variants: [ + { + id: 2, + attributes: [ + { + name: 'ltext', + value: { + en: newLongText, + }, + }, + ], + }, + ], + } + /* eslint-enable max-len */ + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setAttribute', + variantId: 1, + name: 'text', + value: newLongText, + }, + { + action: 'setAttribute', + variantId: 2, + name: 'ltext', + value: { en: newLongText }, + }, + ]) + }) + + test('should build `setAttribute` action', async () => { + const before = { + categories: [], + masterVariant: { + id: 1, + key: 'default-masterVariant-key-de07e9bc-e352-422f-abe6-910d9879b995', + isMasterVariant: true, + prices: [], + images: [], + attributes: [], + assets: [], + }, + } + + const now = { + masterVariant: { + id: 1, + key: 'default-masterVariant-key-de07e9bc-e352-422f-abe6-910d9879b995', + isMasterVariant: true, + attributes: [ + { + name: 'ct-imp-bool', + value: true, + }, + ], + }, + } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setAttribute', + variantId: 1, + name: 'ct-imp-bool', + value: true, + }, + ]) + }) + + test('should build `setPriceMode` action', async () => { + const before = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + } + + const now = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + priceMode: 'Standalone', + } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setPriceMode', + priceMode: 'Standalone', + }, + ]) + }) + + test('should not build `setPriceMode` action if priceModes did not change', async () => { + const before = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + priceMode: 'Embedded', + } + + const now = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + priceMode: 'Embedded', + } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toHaveLength(0) + }) + + test('should change `setPriceMode` action', async () => { + const before = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + priceMode: 'Embedded', + } + + const now = { + masterVariant: { + id: 1, + prices: [], + images: [], + attributes: [], + assets: [], + }, + variants: [], + priceMode: 'Standalone', + } + + const actions = productsSync.buildActions(now, before) + + expect(actions).toEqual([ + { + action: 'setPriceMode', + priceMode: 'Standalone', + }, + ]) + }) + + describe('assets', () => { + test('should build "addAsset" action with empty assets', () => { + const before = { + masterVariant: { + id: 1, + assets: [], + }, + } + const now = { + masterVariant: { + id: 1, + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: now.masterVariant.assets[0], + variantId: 1, + position: 0, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "addAsset" action with non exist assets', () => { + const before = { + masterVariant: { + id: 1, + assets: [ + { + id: 'xyz', + key: 'asset-key-one', + }, + { + id: 'xyz2', + key: 'asset-key-two', + name: { + en: 'asset name two', + }, + }, + ], + }, + } + const now = { + masterVariant: { + id: 1, + assets: [ + { + id: 'xyz', + key: 'asset-key-one', + }, + { + id: 'xyz2', + key: 'asset-key-two', + }, + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: now.masterVariant.assets[2], + variantId: 1, + position: 2, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "addAsset" action for variant with sku with empty assets', () => { + const before = { + variants: [ + { + sku: 'my-sku', + assets: [], + }, + ], + } + const now = { + variants: [ + { + sku: 'my-sku', + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: now.variants[0].assets[0], + sku: 'my-sku', + position: 0, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "addAsset" action with existing assets', () => { + const existingAsset = { + key: 'existing', + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + } + const newAsset = { + key: 'new', + sources: [ + { + uri: 'http://example.org/content/product-manual.gif', + }, + ], + } + const before = { + variants: [ + { + id: 2, + assets: [existingAsset], + }, + ], + } + const now = { + variants: [ + { + id: 2, + assets: [existingAsset, newAsset], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'addAsset', + asset: newAsset, + variantId: 2, + position: 1, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "removeAsset" action with assetId prop', () => { + const before = { + variants: [ + { + id: 2, + assets: [ + { + id: 'c136c9dc-51e8-40fe-8e2e-2a4c159f3358', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + ], + } + const now = { + variants: [ + { + id: 2, + assets: [], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetId: before.variants[0].assets[0].id, + variantId: 2, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "removeAsset" action with assetKey prop', () => { + const before = { + variants: [ + { + id: 2, + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + ], + } + const now = { + variants: [ + { + id: 2, + assets: [], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetKey: before.variants[0].assets[0].key, + variantId: 2, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "removeAsset" action for variant with sku and asset with assetKey prop', () => { + const before = { + variants: [ + { + sku: 'my-sku', + assets: [ + { + key: 'asset-key', + name: { + en: 'asset name ', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + }, + ], + }, + ], + }, + ], + } + const now = { + variants: [ + { + sku: 'my-sku', + assets: [], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'removeAsset', + assetKey: before.variants[0].assets[0].key, + sku: 'my-sku', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "changeAssetName" action when asset name is changed', () => { + const initialAsset = { + id: 'xyz', + key: 'asset-key', + name: { + en: 'asset name ', + }, + } + const changedName = { + name: { + en: 'asset name ', + de: 'Asset Name', + }, + } + const changedAsset = { ...initialAsset, ...changedName } + const before = { + variants: [ + { + id: 1, + assets: [initialAsset], + }, + ], + } + const now = { + variants: [ + { + id: 1, + assets: [changedAsset], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'changeAssetName', + ...changedName, + assetId: 'xyz', + variantId: 1, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "setAssetKey", "changeAssetName", "setAssetDescription", "setAssetSources", "setAssetTags", "setAssetCustomType" actions', () => { + const initialAsset: Asset = { + id: 'xyz', + key: 'asset-key', + name: { + en: 'asset name ', + }, + description: { + en: 'description', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.pdf', + contentType: 'application/pdf', + }, + ], + tags: ['manual', 'pdf'], + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + + const changedAsset: Asset = { + id: 'xyz', + key: 'update-asset-key', + name: { + en: 'update asset name ', + }, + description: { + en: 'update description', + }, + sources: [ + { + uri: 'http://example.org/content/product-manual.xml', + contentType: 'application/xml', + }, + ], + tags: ['manual', 'xml'], + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: false, + }, + }, + } + + const before: DeepPartial = { + variants: [ + { + id: 1, + assets: [initialAsset], + }, + ], + } + const now: DeepPartial = { + variants: [ + { + id: 1, + assets: [changedAsset], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'setAssetKey', + assetKey: 'update-asset-key', + variantId: 1, + assetId: 'xyz', + }, + { + action: 'changeAssetName', + name: { en: 'update asset name ' }, + variantId: 1, + assetId: 'xyz', + }, + { + action: 'setAssetDescription', + description: { en: 'update description' }, + variantId: 1, + assetId: 'xyz', + }, + { + action: 'setAssetTags', + tags: ['manual', 'xml'], + variantId: 1, + assetId: 'xyz', + }, + { + action: 'setAssetSources', + sources: [ + { + uri: 'http://example.org/content/product-manual.xml', + contentType: 'application/xml', + }, + ], + variantId: 1, + assetId: 'xyz', + }, + { + action: 'setAssetCustomType', + variantId: 1, + assetId: 'xyz', + type: { typeId: 'type', id: 'customType2' }, + fields: { customField1: false }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build "setAssetKey", "changeAssetName" and "setAssetCustomField" ', () => { + const initialAsset: DeepPartial = { + id: 'xyz', + key: 'asset-key', + name: { + en: 'asset name ', + }, + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const changedAsset: DeepPartial = { + id: 'xyz', + key: 'asset-key-two', + name: { + en: 'asset name change', + }, + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const before: DeepPartial = { + variants: [ + { + id: 1, + assets: [initialAsset], + }, + ], + } + const now: DeepPartial = { + variants: [ + { + id: 1, + assets: [changedAsset], + }, + ], + } + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'setAssetKey', + assetKey: 'asset-key-two', + variantId: 1, + assetId: 'xyz', + }, + { + action: 'changeAssetName', + name: { + en: 'asset name change', + }, + variantId: 1, + assetId: 'xyz', + }, + { + action: 'setAssetCustomField', + variantId: 1, + assetId: 'xyz', + name: 'customField1', + value: false, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('changeAssetOrder', () => { + const makeAsset = (asset) => { + const base = { + sources: [ + { + uri: 'http://example.org/content/product-manual.xml', + contentType: 'application/xml', + }, + ], + } + return { + ...base, + ...asset, + } + } + + const makeVariant = (assets) => ({ + variants: [ + { + id: 1, + assets, + }, + ], + }) + + test('should build "changeAssetOrder" if assets are simply re-ordered', () => { + const assetOne = makeAsset({ id: 'asset-one' }) + const assetTwo = makeAsset({ id: 'asset-two' }) + const assetThree = makeAsset({ id: 'asset-three' }) + + const before = makeVariant([assetOne, assetTwo, assetThree]) + const now = makeVariant([assetTwo, assetThree, assetOne]) + const actual = productsSync.buildActions(now, before) + const expected = [ + { + action: 'changeAssetOrder', + assetOrder: [assetTwo.id, assetThree.id, assetOne.id], + variantId: 1, + }, + ] + expect(actual).toEqual(expected) + }) + + test('moves to be deleted assets to the end of the ordering', () => { + const assetOne = makeAsset({ id: 'asset-one' }) + const assetTwo = makeAsset({ id: 'asset-two' }) + const assetThree = makeAsset({ id: 'asset-three' }) + + const before = makeVariant([assetOne, assetTwo, assetThree]) + const now = makeVariant([assetTwo, assetOne]) + const actual = productsSync.buildActions(now, before)[0] + const expected = { + action: 'changeAssetOrder', + assetOrder: [assetTwo.id, assetOne.id, assetThree.id], + variantId: 1, + } + + expect(actual).toEqual(expected) + }) + + test('`changeAssetOrder` ignores newly added assets', () => { + const assetOne = makeAsset({ id: 'asset-one' }) + const assetTwo = makeAsset({ id: 'asset-two' }) + const assetThree = makeAsset({ id: 'asset-three' }) + + const before = makeVariant([assetOne, assetTwo]) + const now = makeVariant([assetThree, assetTwo, assetOne]) + const actual = productsSync.buildActions(now, before) + const expected = { + action: 'changeAssetOrder', + assetOrder: [assetTwo.id, assetOne.id], + variantId: 1, + } + + expect(actual[0]).toEqual(expected) + expect(actual[1]).toEqual({ + action: 'addAsset', + asset: assetThree, + variantId: 1, + position: 0, + }) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/product-types-sync-attribute-hints.spec.ts b/packages/sync-actions/test/product-types-sync-attribute-hints.spec.ts new file mode 100644 index 000000000..393ec6bf1 --- /dev/null +++ b/packages/sync-actions/test/product-types-sync-attribute-hints.spec.ts @@ -0,0 +1,529 @@ +import { createSyncProductTypes } from '../src' +import { ProductTypeUpdateAction } from '@commercetools/platform-sdk/src' +import { AttributeEnumValues } from '../src/product-types-actions' + +const createAttributeDefinitionDraftItem = (custom?): AttributeEnumValues => ({ + previous: { + type: { name: 'text' }, + name: 'attribute-name', + label: { en: 'attribute-label' }, + isRequired: false, + attributeConstraint: 'SameForAll', + inputTip: { en: 'input-hint' }, + inputHint: 'SingleLine', + isSearchable: false, + }, + next: { + type: { name: 'text' }, + name: 'attribute-name', + label: { en: 'attribute-label' }, + isRequired: false, + attributeConstraint: 'SameForAll', + inputTip: { en: 'input-hint' }, + inputHint: 'SingleLine', + isSearchable: false, + }, + ...custom, +}) + +const createAttributeEnumDraftItem = ( + custom?: AttributeEnumValues +): AttributeEnumValues => ({ + previous: { + key: 'enum-key', + label: 'enum-label', + }, + next: { + key: 'enum-key', + label: 'enum-label', + }, + hint: { + attributeName: 'attribute-name', + isLocalized: false, + }, + ...custom, +}) + +describe('product type hints', () => { + let updateActions: Array + let sync = createSyncProductTypes([]) + beforeEach(() => { + sync = createSyncProductTypes([]) + }) + describe('attribute enum values', () => { + let attributeEnumDraftItem: AttributeEnumValues + describe('with previous', () => { + describe('with no changes', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem() + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should not generate any update-actions', () => { + expect(updateActions).toEqual([]) + }) + }) + describe('with changes', () => { + describe('when is not localized', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem({ + next: { + key: 'next-key', + label: 'next-label', + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `changeEnumKey` update-action', () => { + expect(updateActions).toEqual( + expect.arrayContaining([ + { + action: 'changeEnumKey', + attributeName: attributeEnumDraftItem.hint.attributeName, + key: 'enum-key', + newKey: 'next-key', + }, + ]) + ) + }) + it('should generate `changePlainEnumLabel` update-action', () => { + expect(updateActions).toEqual( + expect.arrayContaining([ + { + action: 'changePlainEnumValueLabel', + attributeName: attributeEnumDraftItem.hint.attributeName, + newValue: attributeEnumDraftItem.next, + }, + ]) + ) + }) + }) + describe('when is localized', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem({ + next: { + key: 'next-key', + label: 'next-label', + }, + hint: { + isLocalized: true, + attributeName: 'attribute-name', + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `changeEnumKey` update-action', () => { + expect(updateActions).toEqual( + expect.arrayContaining([ + { + action: 'changeEnumKey', + attributeName: attributeEnumDraftItem.hint.attributeName, + key: 'enum-key', + newKey: 'next-key', + }, + ]) + ) + }) + it('should generate `changeLocalizedEnumValueLabel` update-action', () => { + expect(updateActions).toEqual( + expect.arrayContaining([ + { + action: 'changeLocalizedEnumValueLabel', + attributeName: attributeEnumDraftItem.hint.attributeName, + newValue: attributeEnumDraftItem.next, + }, + ]) + ) + }) + }) + + describe('when removing, adding, and editing (in a single batch of actions)', () => { + let attributeEnumDraftItemToBeRemoved1 + let attributeEnumDraftItemToBeRemoved2 + let attributeEnumDraftItemToBeChanged + let attributeEnumDraftItemToBeAdded + beforeEach(() => { + attributeEnumDraftItemToBeRemoved1 = createAttributeEnumDraftItem({ + previous: { key: 'enum-key-1', label: 'enum-label-1' }, + next: undefined, + hint: { + attributeName: 'attribute-enum-with-2-enum-values-to-remove', + isLocalized: false, + }, + }) + attributeEnumDraftItemToBeRemoved2 = createAttributeEnumDraftItem({ + previous: { key: 'enum-key-2', label: 'enum-label-2' }, + next: undefined, + hint: { + attributeName: 'attribute-enum-with-2-enum-values-to-remove', + isLocalized: false, + }, + }) + attributeEnumDraftItemToBeChanged = createAttributeEnumDraftItem({ + next: { + key: 'next-enum-draft-item', + label: undefined, + }, + }) + attributeEnumDraftItemToBeAdded = createAttributeEnumDraftItem({ + previous: undefined, + next: { + key: 'new-enum-draft-item', + label: 'new-enum-draft-item', + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + // we mess around with the order of changes among the hints... + // we should expect that sync-actions gives us a list of changes with the following order: + // [ updateActionsToRemoveEnumValues, updateActionsToUpdateEnumValues, updateActionsToAddEnumValues ] + // when two enumvalues has the same attribute-name, we should also expect that they are "grouped" into a single update action as well. + attributeEnumValues: [ + attributeEnumDraftItemToBeAdded, + attributeEnumDraftItemToBeRemoved1, + attributeEnumDraftItemToBeChanged, + attributeEnumDraftItemToBeRemoved2, + ], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate update-actions (with an explicit order)', () => { + expect(updateActions).toEqual([ + { + action: 'removeEnumValues', + attributeName: 'attribute-enum-with-2-enum-values-to-remove', + keys: ['enum-key-1', 'enum-key-2'], + }, + { + action: 'changeEnumKey', + attributeName: 'attribute-name', + key: 'enum-key', + newKey: 'next-enum-draft-item', + }, + { + action: 'changePlainEnumValueLabel', + attributeName: 'attribute-name', + newValue: { + key: 'next-enum-draft-item', + // this is a possibility on clients. we ought to rely on the API, to return an error + // ref: https://docs.commercetools.com/http-api-projects-productTypes.html#change-the-label-of-an-enumvalue + label: undefined, + }, + }, + { + action: 'addPlainEnumValue', + attributeName: 'attribute-name', + value: attributeEnumDraftItemToBeAdded.next, + }, + ]) + }) + }) + }) + }) + describe('without previous', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem({ + previous: undefined, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `addPlainEnumValue`', () => { + expect(updateActions).toEqual([ + { + action: 'addPlainEnumValue', + attributeName: attributeEnumDraftItem.hint.attributeName, + value: attributeEnumDraftItem.next, + }, + ]) + }) + describe('when is localized', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem({ + previous: undefined, + hint: { + // this hint value is used as `attributeName` for enum update actions + attributeName: 'attribute-name', + isLocalized: true, + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `addLocalizedEnumValue`', () => { + expect(updateActions).toEqual([ + { + action: 'addLocalizedEnumValue', + attributeName: attributeEnumDraftItem.hint.attributeName, + value: attributeEnumDraftItem.next, + }, + ]) + }) + }) + describe('when is truly localized', () => { + beforeEach(() => { + attributeEnumDraftItem = createAttributeEnumDraftItem({ + previous: { label: { 'en-GB': 'uk-label' }, key: 'enum-key' }, + next: { label: { 'en-GB': 'uk-label-new' }, key: 'enum-key' }, + hint: { + // this hint value is used as `attributeName` for enum update actions + attributeName: 'attribute-name', + isLocalized: true, + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeEnumValues: [attributeEnumDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `addLocalizedEnumValue`', () => { + expect(updateActions).toEqual([ + { + action: 'changeLocalizedEnumValueLabel', + attributeName: attributeEnumDraftItem.hint.attributeName, + newValue: attributeEnumDraftItem.next, + }, + ]) + }) + }) + }) + }) + describe('attribute hints', () => { + let attributeDefinitionDraftItem + describe('with previous', () => { + describe('with next', () => { + describe('with no changes', () => { + beforeEach(() => { + attributeDefinitionDraftItem = createAttributeDefinitionDraftItem() + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeDefinitions: [attributeDefinitionDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should not generate any update-actions', () => { + expect(updateActions).toEqual([]) + }) + }) + describe('with changes', () => { + beforeEach(() => { + attributeDefinitionDraftItem = createAttributeDefinitionDraftItem({ + next: { + type: { name: 'boolean' }, + name: 'next-attribute-name', + label: { en: 'next-attribute-label' }, + attributeConstraint: 'None', + inputTip: { en: 'next-input-tip' }, + inputHint: 'MultiLine', + isSearchable: true, + }, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeDefinitions: [attributeDefinitionDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should not generate update action for `name`', () => { + // the API supports changeAttributeName for now, + // however this is not something we support in the node.js for the moment. + expect(updateActions).toEqual( + expect.not.arrayContaining([ + { + action: 'changeAttributeName', + }, + ]) + ) + }) + const changes: Array = [ + [ + 'label', + { + action: 'changeLabel', + attributeName: 'attribute-name', + label: { en: 'next-attribute-label' }, + }, + ], + [ + 'inputTip', + { + action: 'setInputTip', + attributeName: 'attribute-name', + inputTip: { + en: 'next-input-tip', + }, + }, + ], + [ + 'inputHint', + { + action: 'changeInputHint', + attributeName: 'attribute-name', + newValue: 'MultiLine', + }, + ], + [ + 'isSearchable', + { + action: 'changeIsSearchable', + attributeName: 'attribute-name', + isSearchable: true, + }, + ], + [ + 'attributeConstraint', + { + action: 'changeAttributeConstraint', + attributeName: 'attribute-name', + newValue: 'None', + }, + ], + ] + + it.each(changes)( + 'should generate update action for %s', + (name, expectedUpdateAction) => { + expect(updateActions).toEqual( + expect.arrayContaining([expectedUpdateAction]) + ) + } + ) + }) + }) + describe('without next', () => { + beforeEach(() => { + attributeDefinitionDraftItem = createAttributeDefinitionDraftItem({ + next: undefined, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeDefinitions: [attributeDefinitionDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `removeAttributeDefinition` update-action', () => { + expect(updateActions).toEqual( + expect.arrayContaining([ + { + action: 'removeAttributeDefinition', + name: 'attribute-name', + }, + ]) + ) + }) + }) + }) + describe('without previous', () => { + beforeEach(() => { + attributeDefinitionDraftItem = createAttributeDefinitionDraftItem({ + previous: undefined, + }) + updateActions = sync.buildActions( + {}, + {}, + { + nestedValuesChanges: { + attributeDefinitions: [attributeDefinitionDraftItem], + }, + } + ) + }) + it('should match snapshot', () => { + expect(updateActions).toMatchSnapshot() + }) + it('should generate `addAttributeDefinition` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'addAttributeDefinition', + attribute: attributeDefinitionDraftItem.next, + }, + ]) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/product-types-sync-base.spec.ts b/packages/sync-actions/test/product-types-sync-base.spec.ts new file mode 100644 index 000000000..ec05c3e00 --- /dev/null +++ b/packages/sync-actions/test/product-types-sync-base.spec.ts @@ -0,0 +1,365 @@ +import clone from '../src/utils/clone' +import { createSyncProductTypes, actionGroups } from '../src/product-types' +import { + baseActionsList, + generateBaseFieldsUpdateActions, +} from '../src/product-types-actions' +import { DeepPartial } from '../src/types/update-actions' +import { + ProductTypeDraft, + ProductTypeUpdateAction, +} from '@commercetools/platform-sdk/src' + +describe('ProductTypes sync', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'changeDescription', key: 'description' }, + ]) + }) + test('correctly define attribute definitions actions list', () => {}) +}) + +describe('Actions', () => { + let productTypesSync = createSyncProductTypes() + let updateActions: Array + let before: DeepPartial + let now: DeepPartial + beforeEach(() => { + productTypesSync = createSyncProductTypes() + }) + describe('mutation', () => { + test('should ensure given objects are not mutated', () => { + before = { + name: 'Sneakers', + key: 'unique-key', + } + now = { + name: 'Sneakers', + key: 'unique-key-2', + } + const beforeClone = clone(before) + const nowClone = clone(now) + productTypesSync.buildActions(nowClone, beforeClone) + expect(before).toEqual(beforeClone) + expect(now).toEqual(nowClone) + }) + }) + describe('with name change', () => { + beforeEach(() => { + before = { + name: 'Sneakers', + } + now = { + name: 'Kicks', + } + updateActions = productTypesSync.buildActions(now, before) + }) + test('should return `changeName` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'changeName', + name: 'Kicks', + }, + ]) + }) + }) + describe('with key change', () => { + beforeEach(() => { + before = { + key: 'sneakers-key', + } + now = { + key: 'kicks-key', + } + updateActions = productTypesSync.buildActions(now, before) + }) + test('should return `setKey` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'setKey', + key: 'kicks-key', + }, + ]) + }) + }) + describe('with description change', () => { + beforeEach(() => { + before = { + description: 'sneakers-description', + } + now = { + description: 'kicks-description', + } + updateActions = productTypesSync.buildActions(now, before) + }) + test('should return `changeKey` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'changeDescription', + description: 'kicks-description', + }, + ]) + }) + }) + describe('with attribute order change', () => { + beforeEach(() => { + productTypesSync = createSyncProductTypes(undefined, { + shouldOmitEmptyString: false, + withHints: true, + }) + }) + + test('should not generate changeOrder action when the order did not change', () => { + before = { + name: 'Product Type', + attributes: [ + { name: 'attr1' }, + { name: 'attr2' }, // removed + ], + } + now = { + name: 'Product Type', + attributes: [{ name: 'attr1' }], + } + + updateActions = productTypesSync.buildActions(now, before, { + nestedValuesChanges: { + attributeDefinitions: [ + { + previous: { + name: 'attr2', + }, + }, + ], + }, + }) + + expect(updateActions).toEqual([ + { + action: 'removeAttributeDefinition', + name: 'attr2', + }, + ]) + }) + + test('should not generate changeOrder action when removing all attributes', () => { + before = { + name: 'Product Type', + attributes: [ + { name: 'attr1' }, // removed + ], + } + now = { + name: 'Product Type', + } + + updateActions = productTypesSync.buildActions(now, before, { + nestedValuesChanges: { + attributeDefinitions: [ + { + previous: { + name: 'attr1', + }, + }, + ], + }, + }) + + expect(updateActions).toEqual([ + { + action: 'removeAttributeDefinition', + name: 'attr1', + }, + ]) + }) + + test('should not generate changeOrder action when adding first attributes', () => { + before = { + name: 'Product Type', + } + now = { + name: 'Product Type', + attributes: [ + { name: 'attr1' }, // added + ], + } + + updateActions = productTypesSync.buildActions(now, before, { + nestedValuesChanges: { + attributeDefinitions: [ + { + next: { + name: 'attr1', + }, + }, + ], + }, + }) + + expect(updateActions).toEqual([ + { + action: 'addAttributeDefinition', + attribute: { + name: 'attr1', + }, + }, + ]) + }) + + test('should return `changeAttributeOrderByName` update-action', () => { + before = { + name: 'Product Type', + attributes: [{ name: 'color' }, { name: 'material' }], + } + now = { + name: 'Product Type', + attributes: [{ name: 'material' }, { name: 'color' }], + } + + updateActions = productTypesSync.buildActions(now, before) + expect(updateActions).toEqual([ + { + action: 'changeAttributeOrderByName', + attributeNames: ['material', 'color'], + }, + ]) + }) + + test('should remove, add and change order of attributes', () => { + before = { + name: 'Product Type', + attributes: [ + { name: 'attr1' }, + { name: 'attr2' }, // removed + { name: 'attr3' }, + ], + } + now = { + name: 'Product Type', + attributes: [ + { name: 'attr3' }, + { name: 'attr4' }, // added + { name: 'attr1' }, + ], + } + + updateActions = productTypesSync.buildActions(now, before, { + nestedValuesChanges: { + attributeDefinitions: [ + { + previous: { + name: 'attr2', + }, + }, + { + next: { + name: 'attr4', + }, + }, + ], + }, + }) + + expect(updateActions).toEqual([ + { + action: 'removeAttributeDefinition', + name: 'attr2', + }, + { + action: 'addAttributeDefinition', + attribute: { + name: 'attr4', + }, + }, + { + action: 'changeAttributeOrderByName', + attributeNames: ['attr3', 'attr4', 'attr1'], + }, + ]) + }) + }) +}) + +describe('generateBaseFieldsUpdateActions', () => { + let previous + let next + let updateActions + const field = 'name' + const actionDefinition = { + [field]: { action: 'changeName' }, + } + describe('with change', () => { + beforeEach(() => { + previous = { [field]: 'previous' } + next = { [field]: 'next' } + updateActions = generateBaseFieldsUpdateActions( + previous, + next, + actionDefinition + ) + }) + it('should generate `changeName` update action', () => { + expect(updateActions).toEqual([ + { + action: 'changeName', + [field]: next[field], + }, + ]) + }) + describe('with previous and empty `next`', () => { + const cases = [ + [null, { action: 'changeName' }], + [undefined, { action: 'changeName' }], + ['', { action: 'changeName' }], + ] + it.each(cases)( + 'should generate `changeName` for %s update action with omitted field indicating removing value', + (nextValue, updateActionWithMissingValue) => { + next = { [field]: nextValue } + updateActions = generateBaseFieldsUpdateActions( + previous, + next, + actionDefinition + ) + expect(updateActions).toEqual([updateActionWithMissingValue]) + } + ) + }) + }) + describe('without change', () => { + describe('with value on `previous` and `next`', () => { + beforeEach(() => { + previous = { [field]: 'foo' } + next = { [field]: 'foo' } + updateActions = generateBaseFieldsUpdateActions( + previous, + next, + actionDefinition + ) + }) + it('should not generate `changeName` update action', () => { + expect(updateActions).toEqual([]) + }) + }) + describe('without value on `previous` and `next`', () => { + beforeEach(() => { + previous = { [field]: '' } + next = { [field]: '' } + updateActions = generateBaseFieldsUpdateActions( + previous, + next, + actionDefinition + ) + }) + it('should not generate `changeName` update action', () => { + expect(updateActions).toEqual([]) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/projects-sync.spec.ts b/packages/sync-actions/test/projects-sync.spec.ts new file mode 100644 index 000000000..5279de9d8 --- /dev/null +++ b/packages/sync-actions/test/projects-sync.spec.ts @@ -0,0 +1,372 @@ +import { actionGroups, createSyncProjects } from '../src/projects' +import { + baseActionsList, + myBusinessUnitActionsList, + customerSearchActionsList, +} from '../src/projects-actions' +import { ActionGroup } from '@commercetools/sdk-client-v2' +import { DeepPartial } from '../src/types/update-actions' +import { + Project, + ProjectUpdateAction, + SearchIndexingConfiguration, + SearchIndexingConfigurationValues, +} from '@commercetools/platform-sdk' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'myBusinessUnit', 'customerSearch']) + }) + + describe('action list', () => { + test('should contain `changeName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeName', key: 'name' }]) + ) + }) + + test('should contain `changeCurrencies` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeCurrencies', key: 'currencies' }, + ]) + ) + }) + + test('should contain `changeCountries` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeCountries', + key: 'countries', + }, + ]) + ) + }) + + test('should contain `changeLanguages` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeLanguages', + key: 'languages', + }, + ]) + ) + }) + + test('should contain `changeMessagesConfiguration` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeMessagesConfiguration', + key: 'messages', + actionKey: 'messagesConfiguration', + }, + ]) + ) + }) + + test('should contain `setShippingRateInputType` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setShippingRateInputType', + key: 'shippingRateInputType', + }, + ]) + ) + }) + + test('should contain `changeMyBusinessUnitStatusOnCreation` action', () => { + expect(myBusinessUnitActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeMyBusinessUnitStatusOnCreation', + key: 'myBusinessUnitStatusOnCreation', + actionKey: 'status', + }, + ]) + ) + }) + + test('should contain `setMyBusinessUnitAssociateRoleOnCreation` action', () => { + expect(myBusinessUnitActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setMyBusinessUnitAssociateRoleOnCreation', + key: 'myBusinessUnitAssociateRoleOnCreation', + actionKey: 'associateRole', + }, + ]) + ) + }) + + test('should contain `changeCustomerSearchStatus` action', () => { + expect(customerSearchActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeCustomerSearchStatus', + key: 'status', + }, + ]) + ) + }) + }) +}) + +describe('Actions', () => { + let projectsSync = createSyncProjects() + beforeEach(() => { + projectsSync = createSyncProjects() + }) + + test('should build `changeName` action', () => { + const before: DeepPartial = { name: 'nameBefore' } + const now: DeepPartial = { name: 'nameAfter' } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeName', + name: now.name, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeCurrencies` action', () => { + const before: DeepPartial = { currencies: ['EUR', 'Dollar'] } + const now: DeepPartial = { currencies: ['EUR'] } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeCurrencies', + currencies: now.currencies, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeCountries` action', () => { + const before: DeepPartial = { countries: ['Germany', 'Spain'] } + const now: DeepPartial = { countries: ['Germany'] } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeCountries', + countries: now.countries, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeLanguages` action', () => { + const before: DeepPartial = { languages: ['German', 'Dutch'] } + const now: DeepPartial = { languages: ['Dutch'] } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeLanguages', + languages: now.languages, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('setShippingRateInputType', () => { + describe('given `shippingRateInputType` is of type `CartClassification`', () => { + const before: DeepPartial = { + shippingRateInputType: { + type: 'CartClassification', + values: [ + { key: 'Small', label: { en: 'Small', de: 'Klein' } }, + { key: 'Medium', label: { en: 'Medium', de: 'Mittel' } }, + { key: 'Heavy', label: { en: 'Heavy', de: 'Schwergut' } }, + ], + }, + } + describe('given a value of `values` changes', () => { + const now: DeepPartial = { + shippingRateInputType: { + type: 'CartClassification', + values: [ + { key: 'Small', label: { en: 'Small', de: 'Klein' } }, + { key: 'Medium', label: { en: 'Medium', de: 'Mittel' } }, + { key: 'Big', label: { en: 'Big', de: 'Groß' } }, + ], + }, + } + + test('should build `setShippingRateInputType` action', () => { + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'setShippingRateInputType', + shippingRateInputType: { + type: 'CartClassification', + values: [ + { key: 'Small', label: { en: 'Small', de: 'Klein' } }, + { key: 'Medium', label: { en: 'Medium', de: 'Mittel' } }, + { key: 'Big', label: { en: 'Big', de: 'Groß' } }, + ], + }, + }, + ] + expect(actual).toEqual(expected) + }) + }) + describe('given type changes to `CartValue`', () => { + let now: DeepPartial = { + shippingRateInputType: { + type: 'CartValue', + }, + } + + test('should build `setShippingRateInputType` action', () => { + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'setShippingRateInputType', + shippingRateInputType: { + type: 'CartScore', + }, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('given type changes to `CartScore`', () => { + now = { + shippingRateInputType: { + type: 'CartScore', + }, + } + + test('should build `setShippingRateInputType` action', () => { + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'setShippingRateInputType', + shippingRateInputType: { + type: 'CartScore', + }, + }, + ] + expect(actual).toEqual(expected) + }) + }) + }) + }) + }) + + test('should build `changeMessagesConfiguration` action', () => { + const before: DeepPartial = { + messages: { + enabled: true, + deleteDaysAfterCreation: 20, + }, + } + const now: DeepPartial = { + messages: { + enabled: false, + deleteDaysAfterCreation: 20, + }, + } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeMessagesConfiguration', + messagesConfiguration: { enabled: false, deleteDaysAfterCreation: 20 }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeMyBusinessUnitStatusOnCreation` action', () => { + const actionGroupList: Array = [ + { type: 'base', group: 'allow' }, + { type: 'myBusinessUnit', group: 'allow' }, + { type: 'customerSearch', group: 'ignore' }, + ] + projectsSync = createSyncProjects(actionGroupList) + const before: DeepPartial = { + businessUnits: { myBusinessUnitStatusOnCreation: 'Active' }, + } + const now: DeepPartial = { + businessUnits: { myBusinessUnitStatusOnCreation: 'Deactivated' }, + } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeMyBusinessUnitStatusOnCreation', + status: 'Deactivated', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setMyBusinessUnitAssociateRoleOnCreation` action', () => { + const before: DeepPartial = { + businessUnits: { + myBusinessUnitAssociateRoleOnCreation: { + typeId: 'associate-role', + key: 'old-role', + }, + }, + } + const now: DeepPartial = { + businessUnits: { + myBusinessUnitAssociateRoleOnCreation: { + typeId: 'associate-role', + key: 'new-role', + }, + }, + } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'setMyBusinessUnitAssociateRoleOnCreation', + associateRole: { + typeId: 'associate-role', + key: 'new-role', + }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeCustomerSearchStatus` action', () => { + const actionGroupList: Array = [ + { type: 'base', group: 'allow' }, + { type: 'myBusinessUnit', group: 'ignore' }, + { type: 'customerSearch', group: 'allow' }, + ] + projectsSync = createSyncProjects(actionGroupList) + const before: DeepPartial< + Project & { + searchIndexing: SearchIndexingConfiguration & { + customers: SearchIndexingConfigurationValues + } + } + > = { + searchIndexing: { customers: { status: 'Activated' } }, + } + const now: DeepPartial< + Project & { + searchIndexing: SearchIndexingConfiguration & { + customers: SearchIndexingConfigurationValues + } + } + > = { + searchIndexing: { customers: { status: 'Deactivated' } }, + } + const actual = projectsSync.buildActions(now, before) + const expected: Array = [ + { + action: 'changeCustomerSearchStatus', + status: 'Deactivated', + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/quote-requests-sync.spec.ts b/packages/sync-actions/test/quote-requests-sync.spec.ts new file mode 100644 index 000000000..95e26227a --- /dev/null +++ b/packages/sync-actions/test/quote-requests-sync.spec.ts @@ -0,0 +1,131 @@ +import { actionGroups, createSyncQuoteRequest } from '../src/quote-requests' +import { baseActionsList } from '../src/quote-requests-actions' +import { DeepPartial } from '../src/types/update-actions' +import { QuoteRequest } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeQuoteRequestState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeQuoteRequestState', key: 'quoteRequestState' }, + ]) + ) + }) + + test('should contain `transitionState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'transitionState', key: 'state' }]) + ) + }) + }) +}) + +describe('Actions', () => { + let quoteRequestsSync = createSyncQuoteRequest() + beforeEach(() => { + quoteRequestsSync = createSyncQuoteRequest() + }) + + test('should build `changeQuoteRequestState` action', () => { + const before = { quoteRequestState: 'Submitted' } + const now = { quoteRequestState: 'Accepted' } + const actual = quoteRequestsSync.buildActions(now, before) + const expected = [ + { + action: 'changeQuoteRequestState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `transitionState` action', () => { + const before: DeepPartial = { + state: { + typeId: 'state', + id: 'sid1', + }, + } + const now: DeepPartial = { + state: { + typeId: 'state', + id: 'sid2', + }, + } + const actual = quoteRequestsSync.buildActions(now, before) + const expected = [ + { + action: 'transitionState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = quoteRequestsSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = quoteRequestsSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/quotes-sync.spec.ts b/packages/sync-actions/test/quotes-sync.spec.ts new file mode 100644 index 000000000..d3decfd1d --- /dev/null +++ b/packages/sync-actions/test/quotes-sync.spec.ts @@ -0,0 +1,152 @@ +import { actionGroups, createSyncQuote } from '../src/quotes' +import { baseActionsList } from '../src/quotes-actions' +import { DeepPartial } from '../src/types/update-actions' +import { Quote } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeQuoteState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeQuoteState', key: 'quoteState' }, + ]) + ) + }) + + test('should contain `requestQuoteRenegotiation` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'requestQuoteRenegotiation', key: 'buyerComment' }, + ]) + ) + }) + + test('should contain `transitionState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'transitionState', key: 'state' }]) + ) + }) + }) +}) + +describe('Actions', () => { + let quotesSync = createSyncQuote() + beforeEach(() => { + quotesSync = createSyncQuote() + }) + + test('should build `changeQuoteState` action', () => { + const before: DeepPartial = { quoteState: 'Pending' } + const now: DeepPartial = { quoteState: 'Approved' } + const actual = quotesSync.buildActions(now, before) + const expected = [ + { + action: 'changeQuoteState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `requestQuoteRenegotiation` action', () => { + const before: DeepPartial = { buyerComment: '' } + const now: DeepPartial = { buyerComment: 'give me a 10% discount' } + const actual = quotesSync.buildActions(now, before) + const expected = [ + { + action: 'requestQuoteRenegotiation', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `transitionState` action', () => { + const before: DeepPartial = { + state: { + typeId: 'state', + id: 'sid1', + }, + } + const now: DeepPartial = { + state: { + typeId: 'state', + id: 'sid2', + }, + } + const actual = quotesSync.buildActions(now, before) + const expected = [ + { + action: 'transitionState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = quotesSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = quotesSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/shipping-methods.spec.ts b/packages/sync-actions/test/shipping-methods.spec.ts new file mode 100644 index 000000000..efbf1018d --- /dev/null +++ b/packages/sync-actions/test/shipping-methods.spec.ts @@ -0,0 +1,686 @@ +import { + actionGroups, + createSyncShippingMethods, +} from '../src/shipping-methods' +import { baseActionsList } from '../src/shipping-methods-actions' +import { DeepPartial } from '../src/types/update-actions' +import { ShippingMethodDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'zoneRates', 'custom']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'setKey', key: 'key' }, + { action: 'changeName', key: 'name' }, + { action: 'setLocalizedName', key: 'localizedName' }, + { action: 'setDescription', key: 'description' }, + { action: 'setLocalizedDescription', key: 'localizedDescription' }, + { action: 'changeIsDefault', key: 'isDefault' }, + { action: 'setPredicate', key: 'predicate' }, + { action: 'changeTaxCategory', key: 'taxCategory' }, + { action: 'changeActive', key: 'active' }, + ]) + }) +}) + +describe('Actions', () => { + let shippingMethodsSync = createSyncShippingMethods() + beforeEach(() => { + shippingMethodsSync = createSyncShippingMethods() + }) + + describe('base', () => { + test('should build `setKey` action', () => { + const before = { + key: 'Key 1', + } + const now = { + key: 'Key 2', + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [{ action: 'setKey', key: now.key }] + expect(actual).toEqual(expected) + }) + + test('should build `changeName` action', () => { + const before = { + name: 'Shipping Method 1', + } + const now = { + name: 'Shipping Method 2', + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'changeName', + name: now.name, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeActive` action', () => { + const before = { + active: false, + } + const now = { + active: true, + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'changeActive', + active: now.active, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setLocalizedName` action', () => { + const before = { + localizedName: { + en: 'Shipping Method 1', + }, + } + const now = { + localizedName: { + fr: 'Méthode de expédition 1', + en: 'Shipping Method 1', + }, + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setLocalizedName', + localizedName: now.localizedName, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setLocalizedName` action with an empty localizedName', () => { + const before = { + localizedName: { + en: 'Shipping Method 1', + }, + } + const now = { + localizedName: undefined, + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setLocalizedName', + localizedName: now.localizedName, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: 'Custom description', + } + const now = { + description: 'Another description', + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: now.description, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setLocalizedDescription` action', () => { + const before = { + localizedDescription: { + en: 'Custom description', + }, + } + const now = { + localizedDescription: { + fr: 'Description personnalisée', + en: 'Custom description', + }, + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setLocalizedDescription', + localizedDescription: now.localizedDescription, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeIsDefault` action', () => { + const before = { + isDefault: true, + } + const now = { + isDefault: false, + } + + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'changeIsDefault', + isDefault: now.isDefault, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setPredicate` action', () => { + const before: Partial = { + predicate: 'id is defined', + } + const now = { + predicate: 'id is not defined', + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setPredicate', + predicate: now.predicate, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeTaxCategory` action', () => { + const before: Partial = { + taxCategory: { typeId: 'tax-category', id: 'id1' }, + } + const now: Partial = { + taxCategory: { typeId: 'tax-category', id: 'id2' }, + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'changeTaxCategory', taxCategory: now.taxCategory }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('`addZone`', () => { + test('should build `addZone` action with one zone', () => { + const before: DeepPartial = { + zoneRates: [{ zone: { typeId: 'zone', id: 'z1' } }], + } + const now: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z1' } }, + { zone: { typeId: 'zone', id: 'z2' } }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [{ action: 'addZone', zone: now.zoneRates[1].zone }] + expect(actual).toEqual(expected) + }) + + test('should build `addZone` action with multiple zones', () => { + const before: DeepPartial = { + zoneRates: [{ zone: { typeId: 'zone', id: 'z1' } }], + } + const now: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z1' } }, + { zone: { typeId: 'zone', id: 'z3' } }, + { zone: { typeId: 'zone', id: 'z4' } }, + { zone: { typeId: 'zone', id: 'z5' } }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'addZone', zone: now.zoneRates[1].zone }, + { action: 'addZone', zone: now.zoneRates[2].zone }, + { action: 'addZone', zone: now.zoneRates[3].zone }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('`removeZone`', () => { + test('should build `removeZone` removing the last zone item', () => { + const before: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z1' } }, + { zone: { typeId: 'zone', id: 'z2' } }, + ], + } + const now: DeepPartial = { + zoneRates: [{ zone: { typeId: 'zone', id: 'z1' } }], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'removeZone', zone: before.zoneRates[1].zone }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `removeZone` removing all existing zones', () => { + const before: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z1' } }, + { zone: { typeId: 'zone', id: 'z2' } }, + { zone: { typeId: 'zone', id: 'z3' } }, + ], + } + const now: DeepPartial = { + zoneRates: [], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'removeZone', zone: before.zoneRates[0].zone }, + { action: 'removeZone', zone: before.zoneRates[1].zone }, + { action: 'removeZone', zone: before.zoneRates[2].zone }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('`addShippingRate`', () => { + test('should build `addShippingRate` action with one shipping rate', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { centAmount: 1000, currencyCode: 'EUR' } }, + ], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'addShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: now.zoneRates[0].shippingRates[0], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `addShippingRate` action with multiple shipping rate', () => { + const before: DeepPartial = { + zoneRates: [{ zone: { typeId: 'zone', id: 'z1' }, shippingRates: [] }], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { centAmount: 1000, currencyCode: 'EUR' } }, + { price: { centAmount: 1000, currencyCode: 'USD' } }, + ], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'addShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: now.zoneRates[0].shippingRates[0], + }, + { + action: 'addShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: now.zoneRates[0].shippingRates[1], + }, + ] + + expect(actual).toEqual(expected) + }) + }) + + describe('`removeShippingRate`', () => { + test('should build `removeShippingRate` removing one shippingRate', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { centAmount: 1000, currencyCode: 'EUR' } }, + { price: { centAmount: 3000, currencyCode: 'USD' } }, + ], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { centAmount: 1000, currencyCode: 'EUR' } }, + ], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'removeShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: before.zoneRates[0].shippingRates[1], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `removeShippingRate` removing all existing zones', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { centAmount: 1000, currencyCode: 'EUR' } }, + { price: { centAmount: 3000, currencyCode: 'USD' } }, + ], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'removeShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: before.zoneRates[0].shippingRates[0], + }, + { + action: 'removeShippingRate', + zone: before.zoneRates[0].zone, + shippingRate: before.zoneRates[0].shippingRates[1], + }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Swap zones (create one + delete one)', () => { + test('should build `removeZone` and `addZone` when swaping zones', () => { + const before: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z1' } }, + { zone: { typeId: 'zone', id: 'z2' } }, + { zone: { typeId: 'zone', id: 'z3' } }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { zone: { typeId: 'zone', id: 'z4' } }, + { zone: { typeId: 'zone', id: 'z5' } }, + { zone: { typeId: 'zone', id: 'z6' } }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'removeZone', zone: before.zoneRates[0].zone }, + { action: 'addZone', zone: now.zoneRates[0].zone }, + { action: 'removeZone', zone: before.zoneRates[1].zone }, + { action: 'addZone', zone: now.zoneRates[1].zone }, + { action: 'removeZone', zone: before.zoneRates[2].zone }, + { action: 'addZone', zone: now.zoneRates[2].zone }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Swap shippingRates (create one + delete one)', () => { + test('should build `removeShippingRate` and `addShippingRate` when swaping zones', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 1000 } }, + ], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 3000 } }, + ], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'removeShippingRate', + shippingRate: before.zoneRates[0].shippingRates[1], + zone: before.zoneRates[0].zone, + }, + { + action: 'addShippingRate', + shippingRate: now.zoneRates[0].shippingRates[1], + zone: before.zoneRates[0].zone, + }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Multiple actions between zones and shippingRates', () => { + test('should build different actions for updating zones and shippingRates', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 1000 } }, + ], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [], + }, + { + zone: { typeId: 'zone', id: 'z2' }, + shippingRates: [], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'removeShippingRate', + shippingRate: before.zoneRates[0].shippingRates[0], + zone: before.zoneRates[0].zone, + }, + { + action: 'removeShippingRate', + shippingRate: before.zoneRates[0].shippingRates[1], + zone: before.zoneRates[0].zone, + }, + { action: 'addZone', zone: now.zoneRates[1].zone }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('When adding a new zoneRate with zone and shippingRates (fixed rates)', () => { + it('should build different actions for adding zone and shippingRates', () => { + const before: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 1000 } }, + ], + }, + ], + } + const now: DeepPartial = { + zoneRates: [ + { + zone: { typeId: 'zone', id: 'z1' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 1000 } }, + ], + }, + { + zone: { typeId: 'zone', id: 'z2' }, + shippingRates: [ + { price: { currencyCode: 'EUR', centAmount: 1000 } }, + { price: { currencyCode: 'USD', centAmount: 1000 } }, + ], + }, + ], + } + + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { action: 'addZone', zone: now.zoneRates[1].zone }, + { + action: 'addShippingRate', + shippingRate: now.zoneRates[1].shippingRates[0], + zone: now.zoneRates[1].zone, + }, + { + action: 'addShippingRate', + shippingRate: now.zoneRates[1].shippingRates[1], + zone: now.zoneRates[1].zone, + }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + // @ts-ignore + const actual = shippingMethodsSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/sync-actions/test/staged-quotes-sync.spec.ts b/packages/sync-actions/test/staged-quotes-sync.spec.ts new file mode 100644 index 000000000..93d0e7aab --- /dev/null +++ b/packages/sync-actions/test/staged-quotes-sync.spec.ts @@ -0,0 +1,176 @@ +import { + actionGroups, + createSyncStagedQuote, + StagedQuoteSync, +} from '../src/staged-quotes' +import { baseActionsList } from '../src/staged-quotes-actions' +import { DeepPartial } from '../src/types/update-actions' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'custom']) + }) + + describe('action list', () => { + test('should contain `changeStagedQuoteState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'changeStagedQuoteState', key: 'stagedQuoteState' }, + ]) + ) + }) + + test('should contain `setSellerComment` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { action: 'setSellerComment', key: 'sellerComment' }, + ]) + ) + }) + + test('should contain `setValidTo` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setValidTo', key: 'validTo' }]) + ) + }) + + test('should contain `transitionState` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'transitionState', key: 'state' }]) + ) + }) + }) +}) + +describe('Actions', () => { + let stagedQuotesSync = createSyncStagedQuote() + beforeEach(() => { + stagedQuotesSync = createSyncStagedQuote() + }) + + test('should build `changeQuoteState` action', () => { + const before = { stagedQuoteState: 'InProgress' } + const now = { stagedQuoteState: 'Sent' } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [ + { + action: 'changeStagedQuoteState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setSellerComment` action', () => { + const before = { sellerComment: '' } + const now = { + sellerComment: 'let me know if this matches your expectations', + } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [ + { + action: 'setSellerComment', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setValidTo` action', () => { + const before = { validTo: '' } + const now = { validTo: '2022-09-22T15:41:55.816Z' } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [ + { + action: 'setValidTo', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `transitionState` action', () => { + const before: DeepPartial = { + state: { + typeId: 'state', + id: 'sid1', + }, + } + const now: DeepPartial = { + state: { + typeId: 'state', + id: 'sid2', + }, + } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [ + { + action: 'transitionState', + ...now, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = stagedQuotesSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/states-sync.spec.ts b/packages/sync-actions/test/states-sync.spec.ts new file mode 100644 index 000000000..acac96670 --- /dev/null +++ b/packages/sync-actions/test/states-sync.spec.ts @@ -0,0 +1,260 @@ +import { actionGroups, createSyncStates } from '../src/states' +import { baseActionsList } from '../src/state-actions' +import { DeepPartial } from '../src/types/update-actions' +import { StateDraft } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base']) + }) + + describe('action list', () => { + test('should contain `changeIsActive` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'changeKey', key: 'key' }]) + ) + }) + + test('should contain `setName` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([{ action: 'setName', key: 'name' }]) + ) + }) + + test('should contain `setDescription` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setDescription', + key: 'description', + }, + ]) + ) + }) + + test('should contain `changeType` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeType', + key: 'type', + }, + ]) + ) + }) + + test('should contain `changeInitial` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'changeInitial', + key: 'initial', + }, + ]) + ) + }) + + test('should contain `setTransitions` action', () => { + expect(baseActionsList).toEqual( + expect.arrayContaining([ + { + action: 'setTransitions', + key: 'transitions', + }, + ]) + ) + }) + }) +}) + +describe('Actions', () => { + let statesSync = createSyncStates() + beforeEach(() => { + statesSync = createSyncStates() + }) + + test('should build `setName` action', () => { + const before = { + name: { en: 'previous-en-name', de: 'previous-de-name' }, + } + const now = { + name: { en: 'current-en-name', de: 'current-de-name' }, + } + + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'setName', + name: { en: 'current-en-name', de: 'current-de-name' }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: { en: 'old-en-description', de: 'old-de-description' }, + } + const now = { + description: { en: 'new-en-description', de: 'new-de-description' }, + } + + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: { en: 'new-en-description', de: 'new-de-description' }, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeKey` action', () => { + const before = { key: 'oldKey' } + const now = { key: 'newKey' } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'changeKey', + key: 'newKey', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeType` action', () => { + const before = { key: 'state-1', type: 'ReviewState' } + const now = { key: 'state-1', type: 'ProductState' } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'changeType', + type: 'ProductState', + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `changeInitial` action', () => { + const before = { key: 'state-1', initial: true } + const now = { key: 'state-1', initial: false } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'changeInitial', + initial: false, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setTransitions` action', () => { + const before: DeepPartial = { + key: 'state-1', + transitions: [ + { + typeId: 'state', + id: 'old-state-1', + }, + { + typeId: 'state', + id: 'old-state-2', + }, + ], + } + const now: DeepPartial = { + key: 'state-1', + transitions: [ + { + typeId: 'state', + id: 'new-state-1', + }, + { + typeId: 'state', + id: 'new-state-2', + }, + ], + } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'setTransitions', + transitions: [ + { + typeId: 'state', + id: 'new-state-1', + }, + { + typeId: 'state', + id: 'new-state-2', + }, + ], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `addRoles` action', () => { + const before = { + key: 'state-1', + roles: ['ReviewIncludedInStatistics'], + } + const now = { + key: 'state-1', + roles: ['Return', 'Another', 'ReviewIncludedInStatistics'], + } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'addRoles', + roles: ['Return', 'Another'], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `removeRoles` action', () => { + const before = { + key: 'state-1', + roles: ['Return', 'Another', 'ReviewIncludedInStatistics'], + } + const now = { + key: 'state-1', + roles: ['ReviewIncludedInStatistics'], + } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'removeRoles', + roles: ['Return', 'Another'], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build the `removeRoles` and `addRoles` actions', () => { + // This is necessary because there is currently no way to differentiate + // between `setRoles` action and `addRoles || removeRoles` actions, so we + // simply replace the roles that need to be replaced with add and remove + const before = { + key: 'state-1', + roles: ['Return', 'Another', 'Baz'], + } + const now = { + key: 'state-1', + roles: ['Another', 'ReviewIncludedInStatistics', 'Foo'], + } + const actual = statesSync.buildActions(now, before) + const expected = [ + { + action: 'removeRoles', + roles: ['Return', 'Baz'], + }, + { + action: 'addRoles', + roles: ['ReviewIncludedInStatistics', 'Foo'], + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/stores-sync.spec.ts b/packages/sync-actions/test/stores-sync.spec.ts new file mode 100644 index 000000000..17ecf3b14 --- /dev/null +++ b/packages/sync-actions/test/stores-sync.spec.ts @@ -0,0 +1,177 @@ +import { actionGroups, createSyncStores } from '../src/stores' +import { baseActionsList } from '../src/stores-actions' +import { DeepPartial } from '../src/types/update-actions' +import { Store } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'setName', key: 'name' }, + { action: 'setLanguages', key: 'languages' }, + { action: 'setDistributionChannels', key: 'distributionChannels' }, + { action: 'setSupplyChannels', key: 'supplyChannels' }, + ]) + }) +}) + +describe('Actions', () => { + let storesSync = createSyncStores() + beforeEach(() => { + storesSync = createSyncStores() + }) + + test('should build `setName` action', () => { + const before = { + name: { en: 'Algeria' }, + } + const now = { + name: { en: 'Algeria', de: 'Algerian' }, + } + + const actual = storesSync.buildActions(now, before) + const expected = [{ action: 'setName', name: now.name }] + expect(actual).toEqual(expected) + }) + + test('should build `setLanguages` action', () => { + const before = { + languages: ['en'], + } + const now = { + languages: ['en', 'de'], + } + + const actual = storesSync.buildActions(now, before) + const expected = [{ action: 'setLanguages', languages: now.languages }] + expect(actual).toEqual(expected) + }) + + test('should build `setDistributionsChannels` action', () => { + const before: DeepPartial = { + distributionChannels: [ + { + typeId: 'channel', + id: 'pd-001', + }, + ], + } + const now: DeepPartial = { + distributionChannels: [ + { + typeId: 'channel', + id: 'pd-001', + }, + { + typeId: 'channel', + id: 'pd-002', + }, + ], + } + + const actual = storesSync.buildActions(now, before) + expect(actual).toEqual([ + { + action: 'setDistributionChannels', + distributionChannels: now.distributionChannels, + }, + ]) + }) + test('should build `setSupplyChannels` action', () => { + const before: DeepPartial = { + supplyChannels: [ + { + typeId: 'channel', + id: 'inventory-supply-001', + }, + ], + } + const now: DeepPartial = { + supplyChannels: [ + { + typeId: 'channel', + id: 'inventory-supply-001', + }, + { + typeId: 'channel', + id: 'inventory-supply-002', + }, + ], + } + + const actual = storesSync.buildActions(now, before) + expect(actual).toEqual([ + { + action: 'setSupplyChannels', + supplyChannels: now.supplyChannels, + }, + ]) + }) + + describe('custom fields', () => { + test('should build `setCustomType` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = storesSync.buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + test('should build `setCustomField` action', () => { + const before: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + }, + }, + } + const now: DeepPartial = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const actual = storesSync.buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/tax-categories-sync.spec.ts b/packages/sync-actions/test/tax-categories-sync.spec.ts new file mode 100644 index 000000000..02e6331a8 --- /dev/null +++ b/packages/sync-actions/test/tax-categories-sync.spec.ts @@ -0,0 +1,259 @@ +import { actionGroups, createSyncTaxCategories } from '../src/tax-categories' +import { baseActionsList } from '../src/tax-categories-actions' +import { DeepPartial } from '../src/types/update-actions' +import { TaxCategory } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'rates']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'setKey', key: 'key' }, + { action: 'setDescription', key: 'description' }, + ]) + }) +}) + +describe('Actions', () => { + let taxCategorySync = createSyncTaxCategories() + beforeEach(() => { + taxCategorySync = createSyncTaxCategories() + }) + + test('should build `changeName` action', () => { + const before = { + name: 'John', + } + const now = { + name: 'Robert', + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [{ action: 'changeName', name: now.name }] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: 'some description', + } + const now = { + description: 'some updated description', + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: now.description, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `addTaxRate` action', () => { + const before: DeepPartial = {} + const now: DeepPartial = { + rates: [{ name: '5% US', amount: 5 }], + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [{ action: 'addTaxRate', taxRate: now.rates[0] }] + expect(actual).toEqual(expected) + }) + + test('should build `replaceTaxRate` action', () => { + const before: DeepPartial = { + rates: [ + { + id: 'taxRate-1', + name: '5% US', + amount: 5, + }, + ], + } + const now: DeepPartial = { + rates: [ + { + id: 'taxRate-1', + name: '11% US', + amount: 11, + }, + ], + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'replaceTaxRate', + taxRateId: before.rates[0].id, + taxRate: now.rates[0], + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `removeTaxRate` action', () => { + const before = { + rates: [{ id: 'taxRate-1' }], + } + const now = { rates: [] } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'removeTaxRate', + taxRateId: before.rates[0].id, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build complex mixed actions (1)', () => { + const before: DeepPartial = { + rates: [ + { + id: 'taxRate-1', + name: '11% US', + amount: 11, + }, + { + id: 'taxRate-2', + name: '8% DE', + amount: 8, + }, + { + id: 'taxRate-3', + name: '21% ES', + amount: 21, + }, + ], + } + const now: DeepPartial = { + rates: [ + { + id: 'taxRate-1', + name: '11% US', + amount: 11, + country: 'US', + }, + // REMOVED RATE 2 + { + // UNCHANGED RATE 3 + id: 'taxRate-3', + name: '21% ES', + amount: 21, + }, + { + // ADD NEW RATE + id: 'taxRate-4', + name: '15% FR', + amount: 15, + }, + ], + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'replaceTaxRate', + taxRate: { + amount: 11, + country: 'US', // added country to an existing rate + id: 'taxRate-1', + name: '11% US', + }, + taxRateId: 'taxRate-1', + }, + { action: 'removeTaxRate', taxRateId: 'taxRate-2' }, // removed second tax rate + { + action: 'addTaxRate', + taxRate: { amount: 15, id: 'taxRate-4', name: '15% FR' }, // adds new tax rate + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build complex mixed actions (2)', () => { + const before = { + rates: [ + { + id: 'taxRate-1', + name: '11% US', + amount: 11, + }, + { + id: 'taxRate-2', + name: '8% DE', + amount: 8, + }, + { + id: 'taxRate-3', + name: '21% ES', + amount: 21, + }, + ], + } + const now = { + rates: [ + // REMOVED RATE 1 + // REMOVED RATE 2 + { + // CHANGED RATE 3 + id: 'taxRate-3', + name: '21% ES', + state: 'NY', + amount: 21, + }, + { + // ADD NEW RATE + id: 'taxRate-4', + name: '15% FR', + amount: 15, + }, + ], + } + + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'replaceTaxRate', + taxRate: { + amount: 21, + id: 'taxRate-3', + name: '21% ES', + state: 'NY', + }, + taxRateId: 'taxRate-3', + }, + { action: 'removeTaxRate', taxRateId: 'taxRate-1' }, // removed first tax rate + { action: 'removeTaxRate', taxRateId: 'taxRate-2' }, // removed second tax rate + { + action: 'addTaxRate', + taxRate: { amount: 15, id: 'taxRate-4', name: '15% FR' }, // adds new tax rate + }, + ] + + expect(actual).toEqual(expected) + }) + + test('should build `setKey` action', () => { + const before = { + key: '1234', + } + const now = { + key: '4321', + } + const actual = taxCategorySync.buildActions(now, before) + const expected = [ + { + action: 'setKey', + key: now.key, + }, + ] + expect(actual).toEqual(expected) + }) +}) diff --git a/packages/sync-actions/test/types-sync-base.spec.ts b/packages/sync-actions/test/types-sync-base.spec.ts new file mode 100644 index 000000000..9f2d88895 --- /dev/null +++ b/packages/sync-actions/test/types-sync-base.spec.ts @@ -0,0 +1,119 @@ +import clone from '../src/utils/clone' +import { actionGroups, createSyncTypes } from '../src/types' +import { baseActionsList } from '../src/types-actions' +import { TypeUpdateAction } from '@commercetools/platform-sdk/src' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'fieldDefinitions']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeKey', key: 'key' }, + { action: 'changeName', key: 'name' }, + { action: 'setDescription', key: 'description' }, + ]) + }) +}) + +describe('Actions', () => { + let typesSync = createSyncTypes() + let updateActions: Array + let before + let now + beforeEach(() => { + typesSync = createSyncTypes() + }) + describe('mutation', () => { + test('should ensure given objects are not mutated', () => { + before = { + name: 'Orwell', + key: 'War-is-Peace', + } + now = { + name: 'Orwell', + key: 'Freedom-is-slavery', + } + typesSync.buildActions(now, before) + expect(before).toEqual(clone(before)) + expect(now).toEqual(clone(now)) + }) + }) + describe('with name change', () => { + beforeEach(() => { + before = { + name: 'Orwell', + } + now = { + name: 'Ignorance-is-Strength', + } + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeName` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'changeName', + name: 'Ignorance-is-Strength', + }, + ]) + }) + }) + describe('with key change', () => { + beforeEach(() => { + before = { + key: 'orwell-key', + } + now = { + key: 'huxley-key', + } + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeKey` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'changeKey', + key: 'huxley-key', + }, + ]) + }) + }) + describe('with empty key change (shouldOmitEmptyString=false)', () => { + beforeEach(() => { + before = { + key: null, + } + now = { + key: '', + } + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeKey` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'changeKey', + key: '', + }, + ]) + }) + }) + describe('with description change', () => { + beforeEach(() => { + before = { + description: 'orwell-description', + } + now = { + description: 'huxley-description', + } + updateActions = typesSync.buildActions(now, before) + }) + test('should return `setDescription` update-action', () => { + expect(updateActions).toEqual([ + { + action: 'setDescription', + description: 'huxley-description', + }, + ]) + }) + }) +}) diff --git a/packages/sync-actions/test/types-sync-enums.spec.ts b/packages/sync-actions/test/types-sync-enums.spec.ts new file mode 100644 index 000000000..6658bf074 --- /dev/null +++ b/packages/sync-actions/test/types-sync-enums.spec.ts @@ -0,0 +1,617 @@ +import { createSyncTypes } from '../src/types' +import { TypeUpdateAction } from '@commercetools/platform-sdk/src' + +const createTestType = (custom) => ({ + id: 'type-id', + fieldDefinitions: [], + ...custom, +}) + +describe('Actions', () => { + let before + let now + let typesSync = createSyncTypes() + let updateActions: Array + beforeEach(() => { + typesSync = createSyncTypes() + }) + describe('enum values', () => { + describe('with new enum', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { + key: 'enum_1', + label: 'enum-1', + }, + ], + }, + }, + ], + }) + // we get a change operation only here. + updateActions = typesSync.buildActions(now, before) + }) + test('should return `addEnumValue` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'addEnumValue', + fieldName: 'enum', + value: { + key: 'enum_1', + label: 'enum-1', + }, + }, + ]) + }) + }) + describe('with multiple enums added to non-empty stack', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_0', label: 'enum-0' }, + { key: 'enum_2', label: 'enum-2' }, + { key: 'enum_4', label: 'enum-4' }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_1', label: 'enum-1' }, + { key: 'enum_3', label: 'enum-3' }, + { key: 'enum_5', label: 'enum-5' }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return multiple `addEnumValue` updateActions', () => { + expect(updateActions).toEqual([ + { + action: 'addEnumValue', + fieldName: 'enum', + value: { key: 'enum_1', label: 'enum-1' }, + }, + { + action: 'addEnumValue', + fieldName: 'enum', + value: { key: 'enum_3', label: 'enum-3' }, + }, + { + action: 'addEnumValue', + fieldName: 'enum', + value: { key: 'enum_5', label: 'enum-5' }, + }, + ]) + }) + }) + describe('with enum reordered', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_1', label: 'enum-1' }, + { key: 'enum_2', label: 'enum-2' }, + { key: 'enum_4', label: 'enum-4' }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4', label: 'enum-4' }, + { key: 'enum_1', label: 'enum-1' }, + { key: 'enum_2', label: 'enum-2' }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeEnumValueOrder` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeEnumValueOrder', + fieldName: 'enum', + keys: ['enum_4', 'enum_1', 'enum_2'], + }, + ]) + }) + }) + }) + describe('localized enum values', () => { + describe('with new enum', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { + key: 'lenum_1', + label: { + en: 'lenum-en', + de: 'lenum-de', + }, + }, + ], + }, + }, + ], + }) + // we get a change operation only here. + updateActions = typesSync.buildActions(now, before) + }) + test('should return `addLocalizedEnumValue` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'addLocalizedEnumValue', + fieldName: 'lenum', + value: { + key: 'lenum_1', + label: { + en: 'lenum-en', + de: 'lenum-de', + }, + }, + }, + ]) + }) + }) + describe('with added to non-empty stack', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_0', label: { en: 'enum-0' } }, + { key: 'enum_2', label: { en: 'enum-2' } }, + { key: 'enum_4', label: { en: 'enum-4' } }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_0', label: { en: 'enum-0' } }, + { key: 'enum_3', label: { en: 'enum-3' } }, + ], + }, + }, + ], + }) + + updateActions = typesSync.buildActions(now, before) + }) + test('should return `addLocalizedEnumValue` updateAction', () => { + expect(updateActions).toEqual([ + { + fieldName: 'lenum', + action: 'addLocalizedEnumValue', + value: { + key: 'enum_3', + label: { en: 'enum-3' }, + }, + }, + ]) + }) + }) + describe('with lenum reordered', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_1', label: { en: 'enum-1' } }, + { key: 'enum_2', label: { en: 'enum-2' } }, + { key: 'enum_4', label: { en: 'enum-4' } }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_4', label: { en: 'enum-4' } }, + { key: 'enum_1', label: { en: 'enum-1' } }, + { key: 'enum_2', label: { en: 'enum-2' } }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLocalizedEnumValueOrder` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeLocalizedEnumValueOrder', + fieldName: 'lenum', + keys: ['enum_4', 'enum_1', 'enum_2'], + }, + ]) + }) + }) + describe('with Change LocalizedEnumValue Label', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_4_key', label: { en: 'enum-4' } }, + { key: 'enum_1_key', label: { en: 'enum-1' } }, + { key: 'enum_2_key', label: { en: 'enum-2' } }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { key: 'enum_4_key', label: { en: 'updated-enum-4' } }, + { key: 'enum_1_key', label: { en: 'enum-1' } }, + { key: 'enum_2_key', label: { en: 'enum-2' } }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLocalizedEnumValueLabel` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeLocalizedEnumValueLabel', + fieldName: 'lenum', + value: { + key: 'enum_4_key', + label: { en: 'updated-enum-4' }, + }, + }, + ]) + }) + }) + describe('with Change LocalizedEnumValue Label, and lenum reordered ', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { + key: 'enum_1_key', + label: { en: 'enum-1', de: 'Aufzählung-1' }, + }, + { + key: 'enum_2_key', + label: { en: 'enum-2', de: 'Aufzählung-2' }, + }, + { + key: 'enum_4_key', + label: { en: 'enum-4', de: 'Aufzählung-4' }, + }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { + key: 'enum_4_key', + label: { en: 'enum-4', de: 'Aufzählung-4' }, + }, + { + key: 'enum_1_key', + label: { en: 'updated-enum-1', de: 'Aufzählung-1' }, + }, + { + key: 'enum_2_key', + label: { en: 'enum-2', de: 'Aufzählung-2' }, + }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLocalizedEnumValueLabel` `changeLocalizedEnumValueOrder` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeLocalizedEnumValueLabel', + fieldName: 'lenum', + value: { + key: 'enum_1_key', + label: { de: 'Aufzählung-1', en: 'updated-enum-1' }, + }, + }, + { + action: 'changeLocalizedEnumValueOrder', + fieldName: 'lenum', + keys: ['enum_4_key', 'enum_1_key', 'enum_2_key'], + }, + ]) + }) + }) + describe('with Change LocalizedEnumValue Label, should ignore label object ordering', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { + key: 'enum_4_key', + label: { en: 'enum-4', de: 'Aufzählung-4' }, + }, + { + key: 'enum_1_key', + label: { en: 'enum-1', de: 'Aufzählung-1' }, + }, + { + key: 'enum_2_key', + label: { en: 'enum-2', de: 'Aufzählung-2' }, + }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'lenum', + type: { + name: 'lenum', + values: [ + { + key: 'enum_4_key', + label: { de: 'Aufzählung-4', en: 'enum-4' }, + }, + { + key: 'enum_1_key', + label: { de: 'Aufzählung-1', en: 'enum-1' }, + }, + { + key: 'enum_2_key', + label: { de: 'Aufzählung-2', en: 'enum-2' }, + }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return no updateAction', () => { + expect(updateActions).toEqual([]) + }) + }) + describe('with Change EnumValue Label', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4_key', label: 'enum-4' }, + { key: 'enum_1_key', label: 'enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4_key', label: 'updated-enum-4' }, + { key: 'enum_1_key', label: 'enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeEnumValueLabel` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeEnumValueLabel', + fieldName: 'enum', + value: { + key: 'enum_4_key', + label: 'updated-enum-4', + }, + }, + ]) + }) + }) + describe('with Change EnumValue Label, and enum reordered ', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_1_key', label: 'enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + { key: 'enum_4_key', label: 'enum-4' }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4_key', label: 'enum-4' }, + { key: 'enum_1_key', label: 'updated-enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeEnumValueLabel` `changeEnumValueOrder` updateAction', () => { + expect(updateActions).toEqual([ + { + action: 'changeEnumValueLabel', + fieldName: 'enum', + value: { + key: 'enum_1_key', + label: 'updated-enum-1', + }, + }, + { + action: 'changeEnumValueOrder', + fieldName: 'enum', + keys: ['enum_4_key', 'enum_1_key', 'enum_2_key'], + }, + ]) + }) + }) + describe('with Change EnumValue Label, should ignore label object ordering', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4_key', label: 'enum-4' }, + { key: 'enum_1_key', label: 'enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + ], + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'enum', + type: { + name: 'Enum', + values: [ + { key: 'enum_4_key', label: 'enum-4' }, + { key: 'enum_1_key', label: 'enum-1' }, + { key: 'enum_2_key', label: 'enum-2' }, + ], + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return no updateAction', () => { + expect(updateActions).toEqual([]) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/types-sync-fields.spec.ts b/packages/sync-actions/test/types-sync-fields.spec.ts new file mode 100644 index 000000000..42c76ff01 --- /dev/null +++ b/packages/sync-actions/test/types-sync-fields.spec.ts @@ -0,0 +1,409 @@ +import { createSyncTypes } from '../src' +import { TypeUpdateAction } from '@commercetools/platform-sdk/src' + +const createTestType = (custom) => ({ + id: 'type-id', + fieldDefinitions: [], + ...custom, +}) + +describe('Actions', () => { + let typesSync = createSyncTypes() + let updateActions: Array + let before + let now + beforeEach(() => { + typesSync = createSyncTypes() + }) + describe('with new fields', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-field-definition', + label: { + en: 'EN field definition', + }, + }, + { + type: { name: 'number' }, + name: 'number-field-definition', + label: { + en: 'EN field definition', + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `addFieldDefinition` action', () => { + expect(updateActions).toEqual( + now.fieldDefinitions.map((fieldDefinition) => ({ + action: 'addFieldDefinition', + fieldDefinition, + })) + ) + }) + }) + describe('change InputHint', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-field-definition', + label: { + en: 'EN field definition', + }, + inputHint: 'SingleLine', + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-field-definition', + label: { + en: 'EN field definition', + }, + inputHint: 'MultipleLine', + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeInputHint` action', () => { + expect(updateActions).toEqual( + now.fieldDefinitions.map((fieldDefinition) => ({ + action: 'changeInputHint', + fieldName: fieldDefinition.name, + inputHint: 'MultipleLine', + })) + ) + }) + }) + describe('with fieldDefinitions removed', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-field-definition', + label: { + en: 'EN field definition', + }, + }, + { + type: { name: 'number' }, + name: 'number-field-definition', + label: { + en: 'EN field definition', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `removeFieldDefinition` actions', () => { + expect(updateActions).toEqual( + before.fieldDefinitions.map((fieldDefinition) => ({ + action: 'removeFieldDefinition', + fieldName: fieldDefinition.name, + })) + ) + }) + }) + describe('with changing order of fieldDefinitions', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'first', + }, + { + name: 'second', + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'second', + }, + { + name: 'first', + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeFieldDefinitionOrder` action', () => { + expect(updateActions).toEqual([ + { + action: 'changeFieldDefinitionOrder', + fieldNames: ['second', 'first'], + }, + ]) + }) + }) + describe('with fieldDefinitions replaced', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'number' }, + name: 'number-field-definition', + label: { + en: 'number-en', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'text-field-definition', + label: { + en: 'text-en', + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `removeFieldDefinition` and `addFieldDefinition` actions', () => { + expect(updateActions).toEqual([ + { + action: 'removeFieldDefinition', + fieldName: 'number-field-definition', + }, + { + action: 'addFieldDefinition', + fieldDefinition: { + type: { name: 'text' }, + name: 'text-field-definition', + label: { + en: 'text-en', + }, + }, + }, + ]) + }) + }) + describe('when changing field definition label', () => { + describe('when changing single locale value', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: { + en: 'text-en-previous', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: { + en: 'text-en-next', + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLabel` action', () => { + expect(updateActions).toEqual([ + { + action: 'changeLabel', + fieldName: 'name-text-field-definition', + label: { + en: 'text-en-next', + }, + }, + ]) + }) + }) + describe('when changing multiple locale value', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: { + en: 'text-en-previous', + de: 'text-de-previous', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: { + en: 'text-en-next', + de: 'text-de-next', + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLabel` action', () => { + expect(updateActions).toEqual([ + { + action: 'changeLabel', + fieldName: 'name-text-field-definition', + label: { + en: 'text-en-next', + de: 'text-de-next', + }, + }, + ]) + }) + }) + describe('when removing label', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: { + en: 'text-en-previous', + de: 'text-de-previous', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'name-text-field-definition', + label: undefined, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLabel` action', () => { + expect(updateActions).toEqual([ + { + action: 'changeLabel', + fieldName: 'name-text-field-definition', + label: undefined, + }, + ]) + }) + }) + }) + describe('with removal and changing label of fieldDefinitions', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'should-not-change', + label: { + en: 'should-not-change', + }, + }, + { + type: { name: 'text' }, + name: 'should-be-removed', + label: { + en: 'should-be-removed', + }, + }, + { + type: { name: 'text' }, + name: 'should-change', + label: { + en: 'from-this', + }, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + type: { name: 'text' }, + name: 'should-not-change', + label: { + en: 'should-not-change', + }, + }, + { + type: { name: 'text' }, + name: 'should-change', + label: { + en: 'to-this', + }, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return `changeLabel` and `removeFieldDefinition` action', () => { + expect(updateActions).toEqual([ + { action: 'removeFieldDefinition', fieldName: 'should-be-removed' }, + { + action: 'changeLabel', + fieldName: 'should-change', + label: { + en: 'to-this', + }, + }, + ]) + }) + }) + + /** + * there is no update action for fieldDefinition -> required, + * so this field is immutable and unchangeable. + * in case of changing it, this is throwing `Cannot read properties of undefined` cause its nested field. + * below test is making sure this field is ignored and without any internal package errors. + */ + describe('should ignore changes in required field in fieldDefinition', () => { + beforeEach(() => { + before = createTestType({ + fieldDefinitions: [ + { + name: 'first', + required: true, + }, + ], + }) + now = createTestType({ + fieldDefinitions: [ + { + name: 'first', + required: false, + }, + ], + }) + updateActions = typesSync.buildActions(now, before) + }) + test('should return no action', () => { + expect(updateActions).toEqual([]) + }) + }) +}) diff --git a/packages/sync-actions/test/utils/action-map-custom.spec.ts b/packages/sync-actions/test/utils/action-map-custom.spec.ts new file mode 100644 index 000000000..4bafd37c5 --- /dev/null +++ b/packages/sync-actions/test/utils/action-map-custom.spec.ts @@ -0,0 +1,248 @@ +import createBuildActions from '../../src/utils/create-build-actions' +import doMapActions from '../../src/utils/action-map-custom' +import { diff } from '../../src/utils/diffpatcher' + +describe('buildActions', () => { + let buildActions + beforeEach(() => { + buildActions = createBuildActions(diff, doMapActions) + }) + + test('should build `setCustomType` action', () => { + const before = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now = { + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomType` action with key', () => { + const before = { + custom: { + type: { + typeId: 'type', + key: 'customType1', + }, + fields: { + customField1: true, + }, + }, + } + const now = { + custom: { + type: { + typeId: 'type', + key: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action', () => { + const before = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: true, // will change + customField2: true, // will stay unchanged + customField3: false, // will be removed + }, + }, + } + const now = { + custom: { + type: { + typeId: 'type', + id: 'customType1', + }, + fields: { + customField1: false, + customField2: true, + customField4: true, // was added + }, + }, + } + const actual = buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField1', + value: false, + }, + { + action: 'setCustomField', + name: 'customField3', + value: undefined, + }, + { + action: 'setCustomField', + name: 'customField4', + value: true, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setCustomField` action for a long text field', () => { + const before = { + custom: { + type: { + typeId: 'type', + id: 'customType', + }, + fields: { + customField: 'word '.repeat(200), + }, + }, + } + const updatedValue = before.custom.fields.customField.concat('1') + const now = { + custom: { + ...before.custom, + fields: { + customField: updatedValue, + }, + }, + } + const actual = buildActions(now, before) + const expected = [ + { + action: 'setCustomField', + name: 'customField', + value: updatedValue, + }, + ] + expect(actual).toEqual(expected) + }) + + describe('changing the custom type of a category', () => { + describe('existing category has no `custom` object', () => { + test('should build `setCustomType` action with the new type', () => { + const before = { + key: 'category-key', + } + const now = { + key: 'category-key', + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + describe('existing category has an empty `custom` object', () => { + test('should build `setCustomType` action with the new type', () => { + const before = { + key: 'category-key', + custom: {}, + } + const now = { + key: 'category-key', + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType', ...now.custom }] + expect(actual).toEqual(expected) + }) + }) + + describe('existing category has a `custom` object', () => { + describe('new category has no `custom` object', () => { + test('build `setCustomType` action to unset the `custom` type', () => { + const before = { + key: 'category-key', + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const now = { + key: 'category-key', + custom: {}, + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType' }] + expect(actual).toEqual(expected) + }) + }) + + describe('new category has an empty `custom` object', () => { + test('build `setCustomType` action to unset the `custom` type', () => { + const before = { + key: 'category-key', + custom: { + type: { + typeId: 'type', + id: 'customType2', + }, + fields: { + customField1: true, + }, + }, + } + const now = { + key: 'category-key', + // no custom object + } + const actual = buildActions(now, before) + const expected = [{ action: 'setCustomType' }] + expect(actual).toEqual(expected) + }) + test('throw error if either argument function arguments are not provided', () => { + expect(() => buildActions(null, null)).toThrow() + }) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/utils/combine-validity-actions.spec.ts b/packages/sync-actions/test/utils/combine-validity-actions.spec.ts new file mode 100644 index 000000000..72af0c720 --- /dev/null +++ b/packages/sync-actions/test/utils/combine-validity-actions.spec.ts @@ -0,0 +1,65 @@ +import combineValidityActions from '../../src/utils/combine-validity-actions' + +describe('combineValidityActions', () => { + let combinedActions + let validFromAction + let validUntilAction + let otherAction + beforeEach(() => { + validFromAction = { + action: 'setValidFrom', + validFrom: 'date-from-1', + } + validUntilAction = { + action: 'setValidUntil', + validUntil: 'date-until-1', + } + otherAction = { + action: 'changeRequiresDiscountCode', + requiresDiscountCode: true, + } + }) + describe('when both `setValidFrom` and `setValidUntil` available', () => { + beforeEach(() => { + combinedActions = combineValidityActions([ + otherAction, + validFromAction, + validUntilAction, + ]) + }) + it('should combine both actions into `setValidFromAndUntil` action', () => { + expect(combinedActions).toMatchObject([ + otherAction, + { + action: 'setValidFromAndUntil', + validFrom: validFromAction.validFrom, + validUntil: validUntilAction.validUntil, + }, + ]) + }) + }) + describe('when only `setValidFrom` is available', () => { + beforeEach(() => { + combinedActions = combineValidityActions([otherAction, validFromAction]) + }) + it('should not combine into `setValidFromAndUntil` and keep `validFromAction`', () => { + expect(combinedActions).toMatchObject([otherAction, validFromAction]) + }) + }) + describe('when only `setValidUntil` is available', () => { + beforeEach(() => { + combinedActions = combineValidityActions([otherAction, validUntilAction]) + }) + it('should not combine into `setValidFromAndUntil` and keep `validUntilAction`', () => { + expect(combinedActions).toMatchObject([otherAction, validUntilAction]) + }) + }) + describe('when neither `setValidFrom` nor `setValidUntil` are available', () => { + beforeEach(() => { + combinedActions = combineValidityActions([otherAction]) + }) + it('should return same actions without any change', () => { + expect(combinedActions).toMatchObject([otherAction]) + }) + }) +}) diff --git a/packages/sync-actions/test/utils/common-actions.spec.ts b/packages/sync-actions/test/utils/common-actions.spec.ts new file mode 100644 index 000000000..fa237bdfa --- /dev/null +++ b/packages/sync-actions/test/utils/common-actions.spec.ts @@ -0,0 +1,315 @@ +import { + buildBaseAttributesActions, + buildReferenceActions, +} from '../../src/utils/common-actions' +import { diff } from '../../src/utils/diffpatcher' + +describe('Common actions', () => { + describe('::buildBaseAttributesActions', () => { + let actions + let before + let now + const testActions = [ + { + action: 'changeName', + key: 'name', + }, + { + action: 'setDescription', + key: 'description', + }, + { + action: 'setKey', + key: 'key', + }, + { + action: 'setExternalId', + key: 'externalId', + }, + { + action: 'changeSlug', + key: 'slug', + }, + { + action: 'setCustomerNumber', + key: 'customerNumber', + }, + { + action: 'setCustomerNumber', + key: 'customerNumber', + }, + { + action: 'changeQuantity', + key: 'quantityOnStock', + actionKey: 'quantity', + }, + ] + + test('should build base actions', () => { + before = { + name: { en: 'Foo' }, + description: undefined, + externalId: '123', + slug: { en: 'foo' }, + customerNumber: undefined, + quantityOnStock: 1, + } + now = { + name: { en: 'Foo1', de: 'Foo2' }, + description: { en: 'foo bar' }, + externalId: null, + slug: { en: 'foo' }, + customerNumber: null, + quantityOnStock: 0, + } + + actions = buildBaseAttributesActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + + expect(actions).toEqual([ + { action: 'changeName', name: now.name }, + { action: 'setDescription', description: now.description }, + { action: 'setExternalId' }, + { action: 'changeQuantity', quantity: now.quantityOnStock }, + ]) + }) + + describe('with `shouldOmitEmptyString`', () => { + beforeEach(() => { + before = { key: undefined } + now = { key: '' } + actions = buildBaseAttributesActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + shouldOmitEmptyString: true, + }) + }) + it('should not return `setKey` action', () => { + expect(actions).toEqual([]) + }) + }) + + describe('without `shouldOmitEmptyString`', () => { + beforeEach(() => { + before = { key: undefined } + now = { key: '' } + actions = buildBaseAttributesActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + shouldOmitEmptyString: false, + }) + }) + it('should not return `setKey` action', () => { + expect(actions).toEqual([ + { + action: 'setKey', + key: '', + }, + ]) + }) + }) + }) + + describe('::buildReferenceActions', () => { + const testActions = [ + { action: 'setTaxCategory', key: 'taxCategory' }, + { action: 'setCustomerGroup', key: 'customerGroup' }, + { action: 'setSupplyChannel', key: 'supplyChannel' }, + { action: 'setProductType', key: 'productType' }, + { action: 'transitionState', key: 'state' }, + { action: 'setKey', key: 'key' }, + ] + + describe('without expanded references', () => { + describe('taxCategory', () => { + let actions + const before = { + taxCategory: { id: 'tc-1', typeId: 'tax-category' }, + } + const now = { + // id changed + taxCategory: { id: 'tc-2', typeId: 'tax-category' }, + } + + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should build reference action', () => { + expect(actions).toContainEqual({ + action: 'setTaxCategory', + taxCategory: now.taxCategory, + }) + }) + }) + + describe('customerGroup', () => { + let actions + const before = { + customerGroup: undefined, + } + const now = { + // new ref + customerGroup: { id: 'cg-1', typeId: 'customer-group' }, + } + + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should build reference action', () => { + expect(actions).toContainEqual({ + action: 'setCustomerGroup', + customerGroup: now.customerGroup, + }) + }) + }) + + describe('supplyChannel', () => { + let actions + const before = { + supplyChannel: { id: 'sc-1', typeId: 'channel' }, + } + const now = { + // unset + supplyChannel: null, + } + + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should build reference action', () => { + expect(actions).toContainEqual({ action: 'setSupplyChannel' }) + }) + }) + + describe('productType', () => { + let actions + const before = { + productType: { + id: 'pt-1', + typeId: 'product-type', + obj: { id: 'pt-1' }, + }, + } + const now = { + // ignore update + productType: { + id: 'pt-1', + typeId: 'product-type', + }, + } + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should not build reference action', () => { + expect(actions).not.toContainEqual({ action: 'productType' }) + }) + }) + + describe('state', () => { + let actions + const before = { + state: { + id: 's-1', + typeId: 'state', + obj: { id: 's-1' }, + }, + } + const now = { + // new ref: transition state + state: { + id: 's-2', + typeId: 'state', + }, + } + + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should build reference action', () => { + expect(actions).toContainEqual({ + action: 'transitionState', + state: now.state, + }) + }) + }) + }) + + describe('with expanded references', () => { + describe('state', () => { + let actions + const before = { + state: { + id: 's-1', + typeId: 'state', + obj: { id: 's-1' }, + }, + } + const now = { + // new ref: transition state + state: { + id: 's-2', + typeId: 'state', + obj: { id: 's-1' }, + }, + } + + beforeEach(() => { + actions = buildReferenceActions({ + actions: testActions, + diff: diff(before, now), + oldObj: before, + newObj: now, + }) + }) + + test('should build reference action without expansion in action', () => { + expect(actions).toContainEqual({ + action: 'transitionState', + state: { + typeId: now.state.typeId, + id: now.state.id, + }, + }) + }) + }) + }) + }) +}) diff --git a/packages/sync-actions/test/utils/copy-empty-array-props.spec.ts b/packages/sync-actions/test/utils/copy-empty-array-props.spec.ts new file mode 100644 index 000000000..0d5ae5ef8 --- /dev/null +++ b/packages/sync-actions/test/utils/copy-empty-array-props.spec.ts @@ -0,0 +1,331 @@ +import { performance } from 'perf_hooks' +import copyEmptyArrayProps from '../../src/utils/copy-empty-array-props' + +describe('null check on root value', () => { + test('old root value', () => { + const oldObj = null + const newObj = { + metaDescription: { + en: 'new value', + }, + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + expect(fixedNewObj).toEqual(newObj) + expect(old).toEqual(oldObj) + }) + test('new root value', () => { + const oldObj = { + metaDescription: { + en: 'new value', + }, + } + const newObj = null + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + expect(fixedNewObj).toEqual(newObj) + expect(old).toEqual(oldObj) + }) +}) + +test('null check', () => { + const oldObj = { + // typeof `null` === 'object' + metaDescription: null, + } + const newObj = { + metaDescription: { + en: 'new value', + }, + } + const [, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + expect(fixedNewObj).toEqual(newObj) +}) + +test('undefined check', () => { + const [old, fixed] = copyEmptyArrayProps(undefined, undefined) + expect(old).toEqual({}) + expect(fixed).toEqual({}) +}) + +test('should add empty array for undefined prop', () => { + const oldObj = { + emptyArray: [], + anotherProp: 1, + } + const newObj = { + anotherProp: 2, + newObjProp: true, + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ ...newObj, emptyArray: [] }) +}) + +test('should add empty array for `nestedObject`', () => { + const oldObj = { + emptyArray: [], + nestedObject: { + anotherProp: 1, + nestedEmptyArray: [], + }, + anotherProp: 1, + } + + const newObj = { + nestedObject: {}, + anotherProp: 2, + } + + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ + ...newObj, + emptyArray: [], + nestedObject: { + nestedEmptyArray: [], + }, + }) +}) + +test('shouldnt copy `nestedEmptyArrayOne` since parent key not found on `newObj`', () => { + const oldObj = { + nestedObject: { + anotherProp: 1, + nestedObjectOne: { + anotherPropOne: 1, + nestedEmptyArrayOne: [], + }, + }, + anotherProp: 1, + } + + const newObj = { + nestedObject: {}, + anotherProp: 2, + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual(newObj) +}) + +test('should add empty array for `nestedObject`, `nestedObjectOne`', () => { + const oldObj = { + emptyArray: [], + nestedObject: { + anotherProp: 1, + nestedEmptyArray: [], + nestedObjectOne: { + anotherPropOne: 1, + nestedEmptyArrayOne: [], + }, + }, + anotherProp: 1, + } + + const newObj = { + nestedObject: { + nestedObjectOne: { + anotherPropOne: 2, + }, + }, + anotherProp: 2, + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ + ...newObj, + emptyArray: [], + nestedObject: { + nestedEmptyArray: [], + nestedObjectOne: { + anotherPropOne: 2, + nestedEmptyArrayOne: [], + }, + }, + }) +}) + +test('should init empty arrays into nested objects', () => { + const oldObj = { + variants: [ + { + id: 1, + prices: [ + { + id: 1, + customerGroup: [], + }, + { + id: 2, + customerGroup: [], + }, + ], + assets: [], + }, + { + id: 2, + }, + { + id: 3, + prices: [], + }, + { + id: 4, + att: '44444', + prices: [], + }, + ], + } + + const newObj = { + variants: [ + { + id: 1, + prices: [ + { + id: 1, + att: '11111', + p: [], + }, + { + id: 2, + att: '22222', + }, + { + id: 3, + att: '333333', + }, + ], + att: 'anything', + }, + { + id: 4, + }, + ], + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ + variants: [ + { + id: 1, + prices: [ + { + id: 1, + att: '11111', + p: [], + customerGroup: [], + }, + { + id: 2, + att: '22222', + customerGroup: [], + }, + { + id: 3, + att: '333333', + }, + ], + att: 'anything', + assets: [], + }, + { + id: 4, + prices: [], + }, + ], + }) +}) + +test('should ignore dates', () => { + const dateNow = new Date() + const oldObj = { + variants: [ + { + id: 1, + prices: [], + date: dateNow, + }, + ], + } + + const newObj = { + variants: [ + { + id: 1, + date: dateNow, + }, + ], + } + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ + variants: [ + { + id: 1, + prices: [], + date: dateNow, + }, + ], + }) +}) + +test('shouldnt mutate `newObj`', () => { + const oldObj = { + emptyArray: [], + anotherProp: 1, + } + + const newObj = { + anotherProp: 2, + } + + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual({ ...newObj, emptyArray: [] }) + expect(newObj).toEqual({ anotherProp: 2 }) +}) + +test('shouldnt change objects since there is no arrays to copy', () => { + const oldObj = {} + + const newObj = { + customerGroup: { + typeId: 'customer-group', + key: 'foo-customer-group', + }, + } + + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual(newObj) +}) + +test('performance test for large arrays should be less than 200 ms', () => { + const oldObj = { + addresses: Array(5000) + .fill(null) + .map((a, index) => ({ id: `address-${index}` })), + } + + const newObj = { + addresses: Array(5000) + .fill(null) + .map((a, index) => ({ id: `address-${index}` })), + } + + const start = performance.now() + const [old, fixedNewObj] = copyEmptyArrayProps(oldObj, newObj) + const end = performance.now() + + expect(old).toEqual(oldObj) + expect(fixedNewObj).toEqual(newObj) + expect(end - start).toBeLessThan(200) +}) diff --git a/packages/sync-actions/test/utils/create-build-array-actions.spec.ts b/packages/sync-actions/test/utils/create-build-array-actions.spec.ts new file mode 100644 index 000000000..a368ab8f1 --- /dev/null +++ b/packages/sync-actions/test/utils/create-build-array-actions.spec.ts @@ -0,0 +1,155 @@ +import { diff, getDeltaValue } from '../../src/utils/diffpatcher' +import createBuildArrayActions, { + ADD_ACTIONS, + REMOVE_ACTIONS, + CHANGE_ACTIONS, +} from '../../src/utils/create-build-array-actions' + +const testObjKey = 'someNestedObjects' +const getTestObj = (list?: Array) => ({ [testObjKey]: list || [] }) + +describe('createBuildArrayActions', () => { + test('returns function', () => { + expect(typeof createBuildArrayActions('test', {})).toBe('function') + }) + + test('correctly detects add actions', () => { + const before = getTestObj() + const now = getTestObj([{ name: 'a new object' }]) + const addActionSpy = jest.fn() + + const handler = createBuildArrayActions(testObjKey, { + [ADD_ACTIONS]: addActionSpy, + }) + + handler(diff(before, now), before, now) + + expect(addActionSpy).toHaveBeenCalledWith( + { name: 'a new object' }, + expect.any(Number) + ) + + expect(addActionSpy).toHaveBeenCalledWith(expect.any(Object), 0) + }) + + test('correctly detects change actions', () => { + const before = getTestObj([{ name: 'a new object' }]) + const now = getTestObj([{ name: 'a changed object' }]) + const changeActionSpy = jest.fn() + + const handler = createBuildArrayActions(testObjKey, { + [CHANGE_ACTIONS]: changeActionSpy, + }) + + handler(diff(before, now), before, now) + + expect(changeActionSpy).toHaveBeenCalledWith( + { name: 'a new object' }, + { name: 'a changed object' }, + expect.any(Number) + ) + + expect(changeActionSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + 0 + ) + }) + + test('correctly detects remove actions', () => { + const before = getTestObj([{ name: 'an object' }]) + const now = getTestObj() + const removeActionSpy = jest.fn() + + const handler = createBuildArrayActions(testObjKey, { + [REMOVE_ACTIONS]: removeActionSpy, + }) + + handler(diff(before, now), before, now) + + expect(removeActionSpy).toHaveBeenCalledWith( + { name: 'an object' }, + expect.any(Number) + ) + + expect(removeActionSpy).toHaveBeenCalledWith(expect.any(Object), 0) + }) + + test('should throw an error for non array parameter', () => { + expect(() => + getDeltaValue('non-array-imput', { sample: 'object' }) + ).toThrow() + }) + test('throw an error if `originalObject` is not provided', () => { + const sampleArray = [ + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-1' }, + }, + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-2' }, + }, + 2, + ] + expect(() => getDeltaValue(sampleArray, null)).toThrow() + }) + test('throw an error if array is length 3 and second item is 3', () => { + const sampleArray = [ + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-1' }, + }, + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-2' }, + }, + 3, + ] + expect(() => getDeltaValue(sampleArray, null)).toThrow() + }) + test('throw an error if array is length 3 and second item is 4', () => { + const sampleArray = [ + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-1' }, + }, + { + measurements: { + heightInMillimeter: 10, + lengthInMillimeter: 20, + widthInMillimeter: 2, + weightInGram: 5, + }, + trackingData: { trackingId: 'tracking-id-2' }, + }, + 4, + ] + expect(() => getDeltaValue(sampleArray, null)).toThrow() + }) +}) diff --git a/packages/sync-actions/test/utils/create-map-action-group.spec.ts b/packages/sync-actions/test/utils/create-map-action-group.spec.ts new file mode 100644 index 000000000..5beec488b --- /dev/null +++ b/packages/sync-actions/test/utils/create-map-action-group.spec.ts @@ -0,0 +1,96 @@ +import createMapActionGroup from '../../src/utils/create-map-action-group' +import { ActionGroup } from '@commercetools/sdk-client-v2' + +describe('createMapActionGroup', () => { + describe('without actionGroups', () => { + const fn = jest.fn() + let mapActionGroup + + beforeEach(() => { + mapActionGroup = createMapActionGroup([]) + mapActionGroup('foo-type', fn) + }) + + test('should invoke the `fn` (callback)', () => { + expect(fn).toHaveBeenCalled() + }) + }) + + describe('with found `actionGroup` (type)', () => { + describe('with `group` being `allow`', () => { + const fn = jest.fn() + const actionGroups: Array = [ + { type: 'base', group: 'allow' }, + ] + let mapActionGroup + + beforeEach(() => { + mapActionGroup = createMapActionGroup(actionGroups) + mapActionGroup(actionGroups[0].type, fn) + }) + + test('should invoke the `fn` (callback)', () => { + expect(fn).toHaveBeenCalled() + }) + }) + + describe('with `group` being `ignore`', () => { + const fn = jest.fn() + const actionGroups: Array = [ + { type: 'base', group: 'ignore' }, + ] + let mapActionGroup + + beforeEach(() => { + mapActionGroup = createMapActionGroup(actionGroups) + mapActionGroup(actionGroups[0].type, fn) + }) + + test('should not invoke the `fn` (callback)', () => { + expect(fn).not.toHaveBeenCalled() + }) + }) + + describe('without `group`', () => { + const fn = jest.fn() + const actionGroups = [{ type: 'base', group: 'grey' }] + let mapActionGroup + + beforeEach(() => { + mapActionGroup = createMapActionGroup( + actionGroups as Array + ) + }) + + test('should throw an error', () => { + expect(() => { + mapActionGroup(actionGroups[0].type, fn) + }).toThrow() + }) + + test('should throw an error with message', () => { + expect(() => { + mapActionGroup(actionGroups[0].type, fn) + }).toThrow( + `Action group '${actionGroups[0].group}' ` + + 'not supported. Use either "allow" or "ignore".' + ) + }) + }) + }) + + describe('with non found `actionGroup` (type)', () => { + const fn = jest.fn() + const actionGroups: Array = [{ type: 'base', group: 'allow' }] + let mapActionGroup + + beforeEach(() => { + mapActionGroup = createMapActionGroup(actionGroups) + mapActionGroup('foo-non-existent-type', fn) + }) + + test('should not invoke the `fn` (callback)', () => { + expect(fn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/sync-actions/test/utils/extract-matching-pairs.spec.ts b/packages/sync-actions/test/utils/extract-matching-pairs.spec.ts new file mode 100644 index 000000000..d0f374d2c --- /dev/null +++ b/packages/sync-actions/test/utils/extract-matching-pairs.spec.ts @@ -0,0 +1,55 @@ +import extractMatchingPairs from '../../src/utils/extract-matching-pairs' + +describe('extractMatchingPairs', () => { + let key + let path + let oldObj + let newObj + let pairs + describe('with found path', () => { + beforeEach(() => { + key = '0' + path = { + [key]: ['0', '0'], + } + oldObj = [ + { + name: 'foo', + }, + ] + newObj = [ + { + name: 'bar', + }, + ] + pairs = extractMatchingPairs(path, key, oldObj, newObj) + }) + test('should return `newObj` and `oldObj`', () => { + expect(pairs).toEqual({ + oldObj: { name: 'foo' }, + newObj: { name: 'bar' }, + }) + }) + }) + describe('without found pairs', () => { + beforeEach(() => { + key = '0' + path = { + [key]: ['0', '0'], + } + oldObj = [ + { + name: 'foo', + }, + ] + newObj = [] + pairs = extractMatchingPairs(path, key, oldObj, newObj) + }) + test('should return `oldObj` and empty `newObj`', () => { + expect(pairs).toEqual({ + oldObj: { name: 'foo' }, + newObj: undefined, + }) + }) + }) +}) diff --git a/packages/sync-actions/test/utils/find-matching-pairs.spec.ts b/packages/sync-actions/test/utils/find-matching-pairs.spec.ts new file mode 100644 index 000000000..bb84e1210 --- /dev/null +++ b/packages/sync-actions/test/utils/find-matching-pairs.spec.ts @@ -0,0 +1,35 @@ +import findMatchingPairs from '../../src/utils/find-matching-pairs' +import { Delta } from '../../src/utils/diffpatcher' + +describe('findMatchingPairs', () => { + let diff: Delta + let newVariants + let oldVariants + beforeEach(() => { + diff = { + _t: 'a', + _3: ['', 0, 3], + _4: [{ id: 10 }, 0, 0], + _5: ['', 1, 3], + } + oldVariants = [ + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 1 }, + { id: 10 }, + { id: 2 }, + ] + newVariants = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] + }) + + test('should find matching pairs', () => { + const actualResult = findMatchingPairs(diff, oldVariants, newVariants) + const expectedResult = { + _3: ['3', '0'], + _4: ['4', undefined], + _5: ['5', '1'], + } + expect(actualResult).toEqual(expectedResult) + }) +}) diff --git a/packages/sync-actions/test/zones.spec.ts b/packages/sync-actions/test/zones.spec.ts new file mode 100644 index 000000000..51aae55e6 --- /dev/null +++ b/packages/sync-actions/test/zones.spec.ts @@ -0,0 +1,214 @@ +import { actionGroups, createSyncZones } from '../src/zones' +import { baseActionsList } from '../src/zones-actions' + +describe('Exports', () => { + test('action group list', () => { + expect(actionGroups).toEqual(['base', 'locations']) + }) + + test('correctly define base actions list', () => { + expect(baseActionsList).toEqual([ + { action: 'changeName', key: 'name' }, + { action: 'setDescription', key: 'description' }, + { action: 'setKey', key: 'key' }, + ]) + }) +}) + +describe('Actions', () => { + let zonesSync = createSyncZones() + beforeEach(() => { + zonesSync = createSyncZones() + }) + + test('should build `changeName` action', () => { + const before = { + name: 'Europe', + } + const now = { + name: 'Asia', + } + + const actual = zonesSync.buildActions(now, before) + const expected = [{ action: 'changeName', name: now.name }] + expect(actual).toEqual(expected) + }) + + test('should build `setDescription` action', () => { + const before = { + description: 'Zone for Europe', + } + const now = { + description: 'Zone for Asia', + } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { + action: 'setDescription', + description: now.description, + }, + ] + expect(actual).toEqual(expected) + }) + + test('should build `setKey` action', () => { + const before = { + key: 'key-before', + } + + const now = { + key: 'key-now', + } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { + action: 'setKey', + key: 'key-now', + }, + ] + expect(actual).toEqual(expected) + }) + + describe('`addLocation`', () => { + test('should build `addLocation` action with one location', () => { + const before = { locations: [] } + const now = { locations: [{ country: 'Spain' }] } + + const actual = zonesSync.buildActions(now, before) + const expected = [{ action: 'addLocation', location: now.locations[0] }] + expect(actual).toEqual(expected) + }) + test('should build `addLocation` action with two locations', () => { + const before = { locations: [] } + const now = { locations: [{ country: 'Spain' }, { country: 'Italy' }] } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'addLocation', location: now.locations[0] }, + { action: 'addLocation', location: now.locations[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('`removeLocation`', () => { + test('should build `removeLocation` action removing one location', () => { + const before = { + locations: [{ country: 'Spain' }, { country: 'Italy' }], + } + const now = { locations: [{ country: 'Spain' }] } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[1] }, + ] + expect(actual).toEqual(expected) + }) + test('should build `removeLocation` action removing two locations', () => { + const before = { + locations: [{ country: 'Spain' }, { country: 'Italy' }], + } + const now = { locations: [] } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[0] }, + { action: 'removeLocation', location: before.locations[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Swap locations (create one + delete one)', () => { + test('should build `removeLocation` and `addLocation`', () => { + const before = { locations: [{ country: 'Spain' }] } + const now = { locations: [{ country: 'Italy' }] } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[0] }, + { action: 'addLocation', location: now.locations[0] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Multiple actions', () => { + test('should build multiple actions for required changes', () => { + const before = { + locations: [{ country: 'Spain' }, { country: 'France' }], + } + const now = { + locations: [ + { country: 'Italy' }, + { country: 'France' }, + { country: 'Germany' }, + ], + } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[0] }, + { action: 'addLocation', location: now.locations[0] }, + { action: 'addLocation', location: now.locations[2] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Delete first locations', () => { + test('should build multiple actions for required changes', () => { + const before = { + locations: [ + { country: 'Spain' }, + { country: 'Italy' }, + { country: 'France' }, + ], + } + const now = { + locations: [{ country: 'France' }], + } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[0] }, + { action: 'removeLocation', location: before.locations[1] }, + ] + expect(actual).toEqual(expected) + }) + }) + + describe('Delete multiple locations', () => { + test('should build multiple actions for required changes', () => { + const before = { + locations: [ + { country: 'Spain' }, + { country: 'Italy' }, + { country: 'Poland' }, + { country: 'France' }, + { country: 'Portugal' }, + { country: 'Germany' }, + ], + } + const now = { + locations: [ + { country: 'Italy' }, + { country: 'Poland' }, + { country: 'Portugal' }, + { country: 'Russia' }, + ], + } + + const actual = zonesSync.buildActions(now, before) + const expected = [ + { action: 'removeLocation', location: before.locations[0] }, + { action: 'removeLocation', location: before.locations[3] }, + { action: 'addLocation', location: now.locations[3] }, + { action: 'removeLocation', location: before.locations[5] }, + ] + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/sync-actions/tsconfig.json b/packages/sync-actions/tsconfig.json new file mode 100644 index 000000000..a318eeef5 --- /dev/null +++ b/packages/sync-actions/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noImplicitAny": true + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 1d9f4bde9..f15f0b427 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4282,6 +4282,14 @@ chalk@^2.1.0, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -4808,6 +4816,11 @@ detect-newline@^3.0.0: resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +diff-match-patch@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz" @@ -5221,6 +5234,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-equals@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927" + integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w== + fast-glob@^3.0.3, fast-glob@^3.2.11, fast-glob@^3.2.4, fast-glob@^3.2.9: version "3.3.2" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" @@ -6653,6 +6671,14 @@ jsonc-parser@^3.2.0: resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz" integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA== +jsondiffpatch@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.5.0.tgz#f9795416022685a3ba7eced11a338c5cb0cf66f4" + integrity sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw== + dependencies: + chalk "^3.0.0" + diff-match-patch "^1.0.0" + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz"