diff --git a/README.md b/README.md index a31cdabf71..dca22b69ec 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # commercetools sync [![Build Status](https://travis-ci.org/commercetools/commercetools-sync-java.svg?branch=master)](https://travis-ci.org/commercetools/commercetools-sync-java) [![codecov](https://codecov.io/gh/commercetools/commercetools-sync-java/branch/master/graph/badge.svg)](https://codecov.io/gh/commercetools/commercetools-sync-java) -[![Benchmarks 2.2.1](https://img.shields.io/badge/Benchmarks-2.2.1-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) +[![Benchmarks 2.3.0](https://img.shields.io/badge/Benchmarks-2.3.0-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) [![Download](https://api.bintray.com/packages/commercetools/maven/commercetools-sync-java/images/download.svg) ](https://bintray.com/commercetools/maven/commercetools-sync-java/_latestVersion) -[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/) +[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/) [![Known Vulnerabilities](https://snyk.io/test/github/commercetools/commercetools-sync-java/4b2e26113d591bda158217c5dc1cf80a88665646/badge.svg)](https://snyk.io/test/github/commercetools/commercetools-sync-java/4b2e26113d591bda158217c5dc1cf80a88665646) More at https://commercetools.github.io/commercetools-sync-java @@ -37,7 +37,7 @@ The library supports synchronising the following entities in commercetools - [Ivy](#ivy) - [Roadmap](#roadmap) - [Release Notes](/docs/RELEASE_NOTES.md) -- [Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/) +- [Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/) - [Benchmarks](https://commercetools.github.io/commercetools-sync-java/benchmarks/) @@ -79,26 +79,26 @@ Here are the most popular ones: com.commercetools commercetools-sync-java - 2.2.1 + 2.3.0 ```` #### Gradle ````groovy -implementation 'com.commercetools:commercetools-sync-java:2.2.1' +implementation 'com.commercetools:commercetools-sync-java:2.3.0' ```` #### SBT ```` -libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.2.1" +libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.3.0" ```` #### Ivy ````xml - + ```` diff --git a/docs/README.md b/docs/README.md index 5da12b98a6..ba32f71d7e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,9 +2,9 @@ # commercetools sync [![Build Status](https://travis-ci.org/commercetools/commercetools-sync-java.svg?branch=master)](https://travis-ci.org/commercetools/commercetools-sync-java) [![codecov](https://codecov.io/gh/commercetools/commercetools-sync-java/branch/master/graph/badge.svg)](https://codecov.io/gh/commercetools/commercetools-sync-java) -[![Benchmarks 2.2.1](https://img.shields.io/badge/Benchmarks-2.2.1-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) +[![Benchmarks 2.3.0](https://img.shields.io/badge/Benchmarks-2.3.0-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) [![Download](https://api.bintray.com/packages/commercetools/maven/commercetools-sync-java/images/download.svg) ](https://bintray.com/commercetools/maven/commercetools-sync-java/_latestVersion) -[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/) +[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/) [![Known Vulnerabilities](https://snyk.io/test/github/commercetools/commercetools-sync-java/4b2e26113d591bda158217c5dc1cf80a88665646/badge.svg)](https://snyk.io/test/github/commercetools/commercetools-sync-java/4b2e26113d591bda158217c5dc1cf80a88665646) @@ -56,18 +56,18 @@ Here are the most popular ones: com.commercetools commercetools-sync-java - 2.2.1 + 2.3.0 ```` #### Gradle ````groovy -implementation 'com.commercetools:commercetools-sync-java:2.2.1' +implementation 'com.commercetools:commercetools-sync-java:2.3.0' ```` #### SBT ```` -libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.2.1" +libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.3.0" ```` #### Ivy ````xml - + ```` diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 905c99922c..bd1074d8b0 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -13,7 +13,7 @@ 6. Depending on the contents of the release use the subitems below to document the new changes in the release accordingly. Please always include - a link to the releated issue number. + a link to the related issue number. **New Features** (n) πŸŽ‰ **Breaking Changes** (n) 🚧 **Enhancements** (n) ✨ @@ -22,11 +22,21 @@ **Critical Bug Fixes** (n) πŸ”₯ **Bug Fixes** (n)🐞 - **Category Sync** - Sync now supports product variant images syncing. [#114](https://github.com/commercetools/commercetools-sync-java/issues/114) - - **Build Tools** - Convinient handelling of env vars for integration tests. + - **Build Tools** - Convenient handling of env vars for integration tests. 7. Add Migration guide section which specifies explicitly if there are breaking changes and how to tackle them. --> +### 2.3.0 - Oct 15, 2020 +[Commits](https://github.com/commercetools/commercetools-sync-java/compare/2.2.1...2.3.0) | +[Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/) | +[Jar](https://bintray.com/commercetools/maven/commercetools-sync-java/2.3.0) + +- πŸŽ‰ **New Features** (1) + - **Customer Sync** - Added support for syncing customers between ctp projects. [#579](https://github.com/commercetools/commercetools-sync-java/issues/579) + - **Customer Sync** - Introduced `CustomerSyncUtils` which calculates all needed update actions after comparing a `Customer` and a `CustomerDraft`. [#579](https://github.com/commercetools/commercetools-sync-java/issues/579) + - **Customer Sync** - Introduced `CustomerUpdateActionUtils` which contains utils for calculating needed update actions after comparing individual fields of a `Customer` and a `CustomerDraft`. [#579](https://github.com/commercetools/commercetools-sync-java/issues/579) + - **Customer Sync** - Introduced `CustomerReferenceResolutionUtils` which resolves CustomerGroup and Type references from a Customer to a CustomerDraft. [#579](https://github.com/commercetools/commercetools-sync-java/issues/579) ### 2.2.1 - Sep 29, 2020 [Commits](https://github.com/commercetools/commercetools-sync-java/compare/2.2.0...2.2.1) | diff --git a/docs/usage/CART_DISCOUNT_SYNC.md b/docs/usage/CART_DISCOUNT_SYNC.md index 1ee291d075..b8af24c699 100644 --- a/docs/usage/CART_DISCOUNT_SYNC.md +++ b/docs/usage/CART_DISCOUNT_SYNC.md @@ -33,7 +33,7 @@ fields set, otherwise they won't be matched. Types are matched by their `key`s. Therefore, in order for the sync to resolve the actual ids of the type reference, the `key` of the `Type` has to be supplied. - - When syncing from a source commercetools project, you can use [`mapToCartDiscountDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/cartdiscounts/utils/CartDiscountReferenceResolutionUtils.html#mapToCartDiscountDrafts-java.util.List-) + - When syncing from a source commercetools project, you can use [`mapToCartDiscountDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/cartdiscounts/utils/CartDiscountReferenceResolutionUtils.html#mapToCartDiscountDrafts-java.util.List-) method that maps from a `CartDiscount` to `CartDiscountDraft` in order to make them ready for reference resolution by the sync: ````java diff --git a/docs/usage/CATEGORY_SYNC.md b/docs/usage/CATEGORY_SYNC.md index 170ce36281..c61632298f 100644 --- a/docs/usage/CATEGORY_SYNC.md +++ b/docs/usage/CATEGORY_SYNC.md @@ -35,7 +35,7 @@ otherwise they won't be matched. These references are matched by their `key`s. Therefore, in order for the sync to resolve the actual ids of the references, their `key`s has to be supplied. - - When syncing from a source commercetools project, you can use [`mapToCategoryDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/categories/utils/CategoryReferenceResolutionUtils.html#mapToCategoryDrafts-java.util.List-) + - When syncing from a source commercetools project, you can use [`mapToCategoryDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/categories/utils/CategoryReferenceResolutionUtils.html#mapToCategoryDrafts-java.util.List-) method that maps from a `Category` to `CategoryDraft` in order to make them ready for reference resolution by the sync: ````java final List categoryDrafts = CategoryReferenceResolutionUtils.mapToCategoryDrafts(categories); diff --git a/docs/usage/CUSTOMER_SYNC.md b/docs/usage/CUSTOMER_SYNC.md new file mode 100644 index 0000000000..ae0eae43c0 --- /dev/null +++ b/docs/usage/CUSTOMER_SYNC.md @@ -0,0 +1,191 @@ +# Customer Sync + +Module used for importing/syncing Customers into a commercetools project. +It also provides utilities for generating update actions based on the comparison of a [Customer](https://docs.commercetools.com/api/projects/customers#customer) +against a [CustomerDraft](https://docs.commercetools.com/api/projects/customers#customerdraft). + + + +- [Usage](#usage) + - [Sync list of cart discount drafts](#sync-list-of-customer-drafts) + - [Prerequisites](#prerequisites) + - [Running the sync](#running-the-sync) + - [More examples of how to use the sync](#more-examples-of-how-to-use-the-sync) + - [Build all update actions](#build-all-update-actions) + - [Build particular update action(s)](#build-particular-update-actions) +- [Caveats](#caveats) + + +## Usage + +### Sync list of customer drafts + +#### Prerequisites +1. The sync expects a list of `CustomerDraft`s that have their `key` fields set to be matched with customers in the +target CTP project. The customers in the target project need to have the `key` fields set, otherwise they won't be +matched. + +2. To sync customer address data, every customer [Address](https://docs.commercetools.com/api/types#address) needs a +unique key to match the existing `Address` with the new Address. + +3. Every customer may have a reference to their [CustomerGroup](https://docs.commercetools.com/api/projects/customerGroups#customergroup) +and/or the [Type](https://docs.commercetools.com/api/projects/customers#set-custom-type) of their custom fields. +The `CustomerGroup` and `Type` references should be expanded with a key. +Any reference that is not expanded will have its id in place and not replaced by the key will be considered as existing +resources on the target commercetools project and the library will issue an update/create an API request without reference +resolution. + + - When syncing from a source commercetools project, you can use [`mapToCustomerDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.html#mapToCustomerDrafts-java.util.List-) + method that maps from a `Customer` to `CustomerDraft` to make them ready for reference resolution by the sync: + + ````java + final List customerDrafts = CustomerReferenceResolutionUtils.mapToCustomertDrafts(customerDrafts); + ```` + +4. Create a `sphereClient` [as described here](IMPORTANT_USAGE_TIPS.md#sphereclient-creation). + +5. After the `sphereClient` is set up, a `CustomerSyncOptions` should be built as follows: +````java +// instantiating a CustomerSyncOptions +final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder.of(sphereClient).build(); +```` + +[More information about Sync Options](SYNC_OPTIONS.md). + +#### About SyncOptions +`SyncOptions` is an object which provides a place for users to add certain configurations to customize the sync process. +Available configurations: + +##### 1. `errorCallback` +A callback that is called whenever an error event occurs during the sync process. Each resource executes its own +error-callback. When sync process of particular resource runs successfully, it is not triggered. It contains the +following context about the error-event: + +* sync exception +* customer draft from the source +* customer of the target project (only provided if an existing customer could be found) +* the update-actions, which failed (only provided if an existing customer could be found) + +##### Example +````java + final Logger logger = LoggerFactory.getLogger(CustomerSync.class); + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(sphereClient) + .errorCallback((syncException, draft, customer, updateActions) -> + logger.error(new SyncException("My customized message"), syncException)).build(); +```` + +##### 2. `warningCallback` +A callback that is called whenever a warning event occurs during the sync process. Each resource executes its own +warning-callback. When sync process of particular resource runs successfully, it is not triggered. It contains the +following context about the warning message: + +* sync exception +* customer draft from the source +* customer of the target project (only provided if an existing cart discount could be found) + +##### Example +````java + final Logger logger = LoggerFactory.getLogger(CustomerSync.class); + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(sphereClient) + .warningCallback((syncException, draft, customer, updateActions) -> + logger.warn(new SyncException("My customized message"), syncException)).build(); +```` + +##### 3. `beforeUpdateCallback` +During the sync process if a target customer and a customer draft are matched, this callback can be used to +intercept the **_update_** request just before it is sent to commercetools platform. This allows the user to modify +update actions array with custom actions or discard unwanted actions. The callback provides the following information : + + * customer draft from the source + * customer from the target project + * update actions that were calculated after comparing both + +##### Example +````java +final TriFunction>, CustomerDraft, Customer, + List>> beforeUpdateCallback, = + (updateActions, newCustomerDraft, oldCustomer) -> updateActions + .stream() + .filter(updateAction -> !(updateAction instanceof SetLastName)) + .collect(Collectors.toList()); + +final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .beforeUpdateCallback(beforeUpdateCallback) + .build(); +```` + +##### 4. `beforeCreateCallback` +During the sync process if a cart discount draft should be created, this callback can be used to intercept +the **_create_** request just before it is sent to commercetools platform. It contains following information : + + * customer draft that should be created + ##### Example + Please refer to the [example in the product sync document](https://github.com/commercetools/commercetools-sync-java/blob/master/docs/usage/PRODUCT_SYNC.md#example-set-publish-stage-if-category-references-of-given-product-draft-exists). + +##### 5. `batchSize` +A number that could be used to set the batch size with which customers are fetched and processed, +as customers are obtained from the target project on commercetools platform in batches for better performance. The +algorithm accumulates up to `batchSize` resources from the input list, then fetches the corresponding customers +from the target project on commercetools platform in a single request. Playing with this option can slightly improve or +reduce processing speed. If it is not set, the default batch size is 50 for customer sync. +##### Example +````java +final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(sphereClient).batchSize(30).build(); +```` + +#### Running the sync +When all prerequisites are fulfilled, follow those steps to run the sync: + +````java +// instantiating a cart discount sync +final CustomerSync customerSync = new CustomerSync(customerSyncOptions); + +// execute the sync on your list of customers +CompletionStage syncStatisticsStage = customerSync.sync(customerDrafts); +```` +The result of the completing the `syncStatisticsStage` in the previous code snippet contains a `CustomerSyncStatistics` +which contains all the stats of the sync process; which includes a report message, the total number of updated, created, +failed, processed cart discounts, and the processing time of the last sync batch in different time units and in a +human-readable format. + +````java +final CustomerSyncStatistics stats = syncStatisticsStage.toCompletebleFuture().join(); +stats.getReportMessage(); +/*"Summary: 100 customers were processed in total (11 created, 87 updated, 2 failed to sync)."*/ +```` + +__Note__ The statistics object contains the processing time of the last batch only. This is due to two reasons: + + 1. The sync processing time should not take into account the time between supplying batches to the sync. + 2. It is not known by the sync which batch is going to be the last one supplied. + +#### More examples of how to use the sync + + [Sync from an external source](https://github.com/commercetools/commercetools-sync-java/tree/master/src/integration-test/java/com/commercetools/sync/integration/externalsource/customers/CustomerSyncIT.java). + +*Make sure to read the [Important Usage Tips](IMPORTANT_USAGE_TIPS.md) for optimal performance.* + +### Build all update actions +A utility method provided by the library to compare a `Customer` to a new `CustomerDraft`. The results are collected in a list of customer update actions. +```java +List> updateActions = CustomerSyncUtils.buildActions(customer, customerDraft, customerSyncOptions); +``` + +### Build particular update action(s) +The library provides utility methods to compare specific fields of a `Customer` and a new `CustomerDraft`, and builds the update action(s) as a result. +One example is the `buildChangeEmailUpdateAction` which compare email addresses: +````java +Optional> updateAction = CustomerUpdateActionUtils.buildChangeEmailAction(oldCustomer, customerDraft); +```` + +More examples for particular update actions can be found in the test scenarios for [CustomerUpdateActionUtils](https://github.com/commercetools/commercetools-sync-java/tree/master/src/test/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtilsTest.java) +and [AddressUpdateActionUtils](https://github.com/commercetools/commercetools-sync-java/tree/master/src/test/java/com/commercetools/sync/customers/utils/AddressUpdateActionUtilsTest.java). + + +## Caveats +The library does not support the synchronization of the `password` field of existing customers. +For customers that do not exist in the project, a password will be created with the given customer draft’s password. diff --git a/docs/usage/IMPORTANT_USAGE_TIPS.md b/docs/usage/IMPORTANT_USAGE_TIPS.md index 6f949053f0..1d22f421da 100644 --- a/docs/usage/IMPORTANT_USAGE_TIPS.md +++ b/docs/usage/IMPORTANT_USAGE_TIPS.md @@ -31,7 +31,7 @@ productSync.sync(batch1) By design, scaling the sync process should **not** be done by executing the batches themselves in parallel. However, it can be done either by: - Changing the number of [max parallel requests](https://github.com/commercetools/commercetools-sync-java/tree/master/src/main/java/com/commercetools/sync/commons/utils/ClientConfigurationUtils.java#L116) within the `sphereClient` configuration. It defines how many requests the client can execute in parallel. - - or changing the draft [batch size](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/commons/BaseSyncOptionsBuilder.html#batchSize-int-). It defines how many drafts can one batch contain. + - or changing the draft [batch size](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/commons/BaseSyncOptionsBuilder.html#batchSize-int-). It defines how many drafts can one batch contain. The current overridable default [configuration](https://github.com/commercetools/commercetools-sync-java/tree/master/src/main/java/com/commercetools/sync/commons/utils/ClientConfigurationUtils.java#L45) of the `sphereClient` is the recommended good balance for stability and performance for the sync process. diff --git a/docs/usage/INVENTORY_SYNC.md b/docs/usage/INVENTORY_SYNC.md index 972f4c57bf..713f73b998 100644 --- a/docs/usage/INVENTORY_SYNC.md +++ b/docs/usage/INVENTORY_SYNC.md @@ -33,7 +33,7 @@ against a [InventoryEntryDraft](https://docs.commercetools.com/http-api-projects references are matched by their `key`s. Therefore, in order for the sync to resolve the actual ids of those references, their `key`s has to be supplied. - - When syncing from a source commercetools project, you can use [`mapToInventoryEntryDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/inventories/utils/InventoryReferenceResolutionUtils.html#mapToInventoryEntryDrafts-java.util.List-) + - When syncing from a source commercetools project, you can use [`mapToInventoryEntryDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/inventories/utils/InventoryReferenceResolutionUtils.html#mapToInventoryEntryDrafts-java.util.List-) method that that maps from a `InventoryEntry` to `InventoryEntryDraft` in order to make them ready for reference resolution by the sync: ````java final List inventoryEntryDrafts = InventoryReferenceResolutionUtils.mapToInventoryEntryDrafts(inventoryEntries); diff --git a/docs/usage/PRODUCT_SYNC.md b/docs/usage/PRODUCT_SYNC.md index c812ca430e..4489b9841c 100644 --- a/docs/usage/PRODUCT_SYNC.md +++ b/docs/usage/PRODUCT_SYNC.md @@ -39,7 +39,7 @@ all the variants in the target project are expected to have the `sku` fields set of the product also have prices, where each price also has some references including a reference to the `Type` of its custom fields and a reference to a `channel`. All these referenced resources are matched by their `key`s. Therefore, in order for the sync to resolve the actual ids of those references, those `key`s have to be supplied in the following way: - - When syncing from a source commercetools project, you can use [`mapToProductDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/products/utils/ProductReferenceResolutionUtils.html#mapToProductDrafts-java.util.List-) + - When syncing from a source commercetools project, you can use [`mapToProductDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/products/utils/ProductReferenceResolutionUtils.html#mapToProductDrafts-java.util.List-) method that maps from a `Product` to `ProductDraft` in order to make them ready for reference resolution by the sync: ````java final List productDrafts = ProductReferenceResolutionUtils.mapToProductDrafts(products); diff --git a/docs/usage/PRODUCT_TYPE_SYNC.md b/docs/usage/PRODUCT_TYPE_SYNC.md index 0f57d41d11..4e347da38d 100644 --- a/docs/usage/PRODUCT_TYPE_SYNC.md +++ b/docs/usage/PRODUCT_TYPE_SYNC.md @@ -39,7 +39,7 @@ references, those `key`s have to be supplied in the following way: - Provide the `key` value on the `id` field of the reference. This means that calling `getId()` on the reference would return its `key`. - **Note**: When syncing from a source commercetools project, you can use [`mapToProductTypeDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.2.1/com/commercetools/sync/producttypes/utils/ProductTypeReferenceResolutionUtils.html#mapToProductTypeDrafts-java.util.List-) + **Note**: When syncing from a source commercetools project, you can use [`mapToProductTypeDrafts`](https://commercetools.github.io/commercetools-sync-java/v/2.3.0/com/commercetools/sync/producttypes/utils/ProductTypeReferenceResolutionUtils.html#mapToProductTypeDrafts-java.util.List-) that replaces the references id fields with keys, in order to make them ready for reference resolution by the sync: ````java diff --git a/docs/usage/QUICK_START.md b/docs/usage/QUICK_START.md index 80a026e702..5a0834bc3d 100644 --- a/docs/usage/QUICK_START.md +++ b/docs/usage/QUICK_START.md @@ -37,7 +37,7 @@ com.commercetools commercetools-sync-java - 2.2.1 + 2.3.0 ```` - For Gradle users: @@ -48,7 +48,7 @@ implementation 'com.commercetools.sdk.jvm.core:commercetools-java-client:1.53.0' implementation 'com.commercetools.sdk.jvm.core:commercetools-convenience:1.53.0' // Add commercetools-sync-java dependency. -implementation 'com.commercetools:commercetools-sync-java:2.2.1' +implementation 'com.commercetools:commercetools-sync-java:2.3.0' ```` ### 2. Setup Syncing Options diff --git a/mkdocs.yml b/mkdocs.yml index ee7f87cf42..9b48ed552e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,10 +56,11 @@ nav: - TaxCategory Sync: usage/TAX_CATEGORY_SYNC.md - State Sync: usage/STATE_SYNC.md - CustomObject Sync: usage/CUSTOM_OBJECT_SYNC.md + - Customer Sync: usage/CUSTOMER_SYNC.md - Advanced: - Sync Options: usage/SYNC_OPTIONS.md - Usage Tips: usage/IMPORTANT_USAGE_TIPS.md - - Javadoc: https://commercetools.github.io/commercetools-sync-java/v/2.2.1/ + - Javadoc: https://commercetools.github.io/commercetools-sync-java/v/2.3.0/ - Release notes: RELEASE_NOTES.md - Roadmap: https://github.com/commercetools/commercetools-sync-java/milestones - Issues: https://github.com/commercetools/commercetools-sync-java/issues diff --git a/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerGroupITUtils.java b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerGroupITUtils.java index 0522e91c5e..1efff64bab 100644 --- a/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerGroupITUtils.java +++ b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerGroupITUtils.java @@ -11,19 +11,9 @@ import javax.annotation.Nonnull; import static com.commercetools.sync.integration.commons.utils.ITUtils.queryAndExecute; -import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_SOURCE_CLIENT; -import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; import static com.commercetools.tests.utils.CompletionStageUtil.executeBlocking; public final class CustomerGroupITUtils { - /** - * Deletes all CustomerGroup from CTP projects defined by the {@code CTP_SOURCE_CLIENT} and - * {@code CTP_TARGET_CLIENT}. - */ - public static void deleteCustomerGroupsFromTargetAndSource() { - deleteCustomerGroups(CTP_TARGET_CLIENT); - deleteCustomerGroups(CTP_SOURCE_CLIENT); - } /** * Deletes all CustomerGroups from the CTP project defined by the {@code ctpClient}. diff --git a/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerITUtils.java b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerITUtils.java new file mode 100644 index 0000000000..a02f3cc9a7 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/CustomerITUtils.java @@ -0,0 +1,174 @@ +package com.commercetools.sync.integration.commons.utils; + +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.CustomerSignInResult; +import io.sphere.sdk.customers.commands.CustomerCreateCommand; +import io.sphere.sdk.customers.commands.CustomerDeleteCommand; +import io.sphere.sdk.customers.queries.CustomerQuery; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.types.CustomFieldsDraft; +import io.sphere.sdk.types.ResourceTypeIdsSetBuilder; +import io.sphere.sdk.types.Type; +import org.apache.commons.lang3.tuple.ImmutablePair; + +import javax.annotation.Nonnull; +import java.time.LocalDate; +import java.util.Locale; + +import static com.commercetools.sync.integration.commons.utils.CustomerGroupITUtils.createCustomerGroup; +import static com.commercetools.sync.integration.commons.utils.CustomerGroupITUtils.deleteCustomerGroups; +import static com.commercetools.sync.integration.commons.utils.ITUtils.createTypeIfNotAlreadyExisting; +import static com.commercetools.sync.integration.commons.utils.ITUtils.deleteTypes; +import static com.commercetools.sync.integration.commons.utils.ITUtils.queryAndExecute; +import static com.commercetools.sync.integration.commons.utils.StoreITUtils.createStore; +import static com.commercetools.sync.integration.commons.utils.StoreITUtils.deleteStores; +import static com.commercetools.tests.utils.CompletionStageUtil.executeBlocking; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; + +public final class CustomerITUtils { + + /** + * Deletes all customers, types, stores and customer groups from the CTP project defined by the {@code ctpClient}. + * + * @param ctpClient defines the CTP project to delete the customer sync test data. + */ + public static void deleteCustomerSyncTestData(@Nonnull final SphereClient ctpClient) { + deleteCustomers(ctpClient); + deleteTypes(ctpClient); + deleteStores(ctpClient); + deleteCustomerGroups(ctpClient); + } + + /** + * Deletes all customers from CTP project, represented by provided {@code ctpClient}. + * + * @param ctpClient represents the CTP project the customers will be deleted from. + */ + public static void deleteCustomers(@Nonnull final SphereClient ctpClient) { + queryAndExecute(ctpClient, CustomerQuery.of(), CustomerDeleteCommand::of); + } + + public static ImmutablePair createSampleCustomerJohnDoe( + @Nonnull final SphereClient ctpClient) { + + final Store storeBerlin = createStore(ctpClient, "store-berlin"); + final Store storeHamburg = createStore(ctpClient, "store-hamburg"); + final Store storeMunich = createStore(ctpClient, "store-munich"); + + final Type customTypeGoldMember = createCustomerCustomType("customer-type-gold", Locale.ENGLISH, + "gold customers", ctpClient); + + final CustomerGroup customerGroupGoldMembers = createCustomerGroup(ctpClient, "gold members", "gold"); + + final CustomerDraft customerDraftJohnDoe = CustomerDraftBuilder + .of("john@example.com", "12345") + .customerNumber("gold-1") + .key("customer-key-john-doe") + .stores(asList( + ResourceIdentifier.ofKey(storeBerlin.getKey()), + ResourceIdentifier.ofKey(storeHamburg.getKey()), + ResourceIdentifier.ofKey(storeMunich.getKey()))) + .firstName("John") + .lastName("Doe") + .middleName("Jr") + .title("Mr") + .salutation("Dear") + .dateOfBirth(LocalDate.now().minusYears(28)) + .companyName("Acme Corporation") + .vatId("DE999999999") + .emailVerified(true) + .customerGroup(ResourceIdentifier.ofKey(customerGroupGoldMembers.getKey())) + .addresses(asList( + Address.of(CountryCode.DE).withCity("berlin").withKey("address1"), + Address.of(CountryCode.DE).withCity("hamburg").withKey("address2"), + Address.of(CountryCode.DE).withCity("munich").withKey("address3"))) + .defaultBillingAddress(0) + .billingAddresses(asList(0, 1)) + .defaultShippingAddress(2) + .shippingAddresses(singletonList(2)) + .custom(CustomFieldsDraft.ofTypeKeyAndJson(customTypeGoldMember.getKey(), emptyMap())) + .locale(Locale.ENGLISH) + .build(); + + final Customer customer = createCustomer(ctpClient, customerDraftJohnDoe); + return ImmutablePair.of(customer, customerDraftJohnDoe); + } + + public static void createSampleCustomerJaneDoe(@Nonnull final SphereClient ctpClient) { + final CustomerDraft customerDraftJaneDoe = CustomerDraftBuilder + .of("jane@example.com", "12345") + .customerNumber("random-1") + .key("customer-key-jane-doe") + .firstName("Jane") + .lastName("Doe") + .middleName("Jr") + .title("Miss") + .salutation("Dear") + .dateOfBirth(LocalDate.now().minusYears(25)) + .companyName("Acme Corporation") + .vatId("FR000000000") + .emailVerified(false) + .addresses(asList( + Address.of(CountryCode.DE).withCity("cologne").withKey("address1"), + Address.of(CountryCode.DE).withCity("berlin").withKey("address2"))) + .defaultBillingAddress(0) + .billingAddresses(singletonList(0)) + .defaultShippingAddress(1) + .shippingAddresses(singletonList(1)) + .locale(Locale.ENGLISH) + .build(); + + createCustomer(ctpClient, customerDraftJaneDoe); + } + + /** + * Creates a {@link Customer} in the CTP project defined by the {@code ctpClient} in a blocking fashion. + * + * @param ctpClient defines the CTP project to create the CustomerGroup in. + * @param customerDraft the draft of the customer to create. + * @return the created customer. + */ + public static Customer createCustomer( + @Nonnull final SphereClient ctpClient, + @Nonnull final CustomerDraft customerDraft) { + + final CustomerSignInResult customerSignInResult = executeBlocking( + ctpClient.execute(CustomerCreateCommand.of(customerDraft))); + return customerSignInResult.getCustomer(); + } + + /** + * This method blocks to create a customer custom type on the CTP project defined by the supplied + * {@code ctpClient}, with the supplied data. + * + * @param typeKey the type key + * @param locale the locale to be used for specifying the type name and field definitions names. + * @param name the name of the custom type. + * @param ctpClient defines the CTP project to create the type on. + */ + public static Type createCustomerCustomType( + @Nonnull final String typeKey, + @Nonnull final Locale locale, + @Nonnull final String name, + @Nonnull final SphereClient ctpClient) { + + return createTypeIfNotAlreadyExisting( + typeKey, + locale, + name, + ResourceTypeIdsSetBuilder.of().addCustomers(), + ctpClient); + } + + private CustomerITUtils() { + } +} diff --git a/src/integration-test/java/com/commercetools/sync/integration/commons/utils/StoreITUtils.java b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/StoreITUtils.java new file mode 100644 index 0000000000..76976d97c2 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/commons/utils/StoreITUtils.java @@ -0,0 +1,51 @@ +package com.commercetools.sync.integration.commons.utils; + +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.stores.StoreDraft; +import io.sphere.sdk.stores.StoreDraftBuilder; +import io.sphere.sdk.stores.commands.StoreCreateCommand; +import io.sphere.sdk.stores.commands.StoreDeleteCommand; +import io.sphere.sdk.stores.queries.StoreQuery; + +import javax.annotation.Nonnull; + +import static com.commercetools.sync.integration.commons.utils.ITUtils.queryAndExecute; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_SOURCE_CLIENT; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; +import static com.commercetools.tests.utils.CompletionStageUtil.executeBlocking; + +public final class StoreITUtils { + /** + * Deletes all stores from CTP projects defined by the {@code CTP_SOURCE_CLIENT} and + * {@code CTP_TARGET_CLIENT}. + */ + public static void deleteStoresFromTargetAndSource() { + deleteStores(CTP_TARGET_CLIENT); + deleteStores(CTP_SOURCE_CLIENT); + } + + /** + * Deletes all stores from the CTP project defined by the {@code ctpClient}. + * + * @param ctpClient defines the CTP project to delete the stores from. + */ + public static void deleteStores(@Nonnull final SphereClient ctpClient) { + queryAndExecute(ctpClient, StoreQuery.of(), StoreDeleteCommand::of); + } + + /** + * Creates a {@link Store} in the CTP project defined by the {@code ctpClient} in a blocking fashion. + * + * @param ctpClient defines the CTP project to create the Store in. + * @param key the key of the Store to create. + * @return the created store. + */ + public static Store createStore(@Nonnull final SphereClient ctpClient, @Nonnull final String key) { + final StoreDraft storeDraft = StoreDraftBuilder.of(key).build(); + return executeBlocking(ctpClient.execute(StoreCreateCommand.of(storeDraft))); + } + + private StoreITUtils() { + } +} diff --git a/src/integration-test/java/com/commercetools/sync/integration/ctpprojectsource/customers/CustomerSyncIT.java b/src/integration-test/java/com/commercetools/sync/integration/ctpprojectsource/customers/CustomerSyncIT.java new file mode 100644 index 0000000000..610ea3f204 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/ctpprojectsource/customers/CustomerSyncIT.java @@ -0,0 +1,147 @@ +package com.commercetools.sync.integration.ctpprojectsource.customers; + +import com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics; +import com.commercetools.sync.customers.CustomerSync; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.types.CustomFieldsDraft; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics.assertThat; +import static com.commercetools.sync.customers.utils.CustomerReferenceResolutionUtils.buildCustomerQuery; +import static com.commercetools.sync.customers.utils.CustomerReferenceResolutionUtils.mapToCustomerDrafts; +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.createSampleCustomerJaneDoe; +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.createSampleCustomerJohnDoe; +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.deleteCustomerSyncTestData; +import static com.commercetools.sync.integration.commons.utils.ITUtils.createCustomFieldsJsonMap; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_SOURCE_CLIENT; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; +import static com.commercetools.sync.integration.commons.utils.StoreITUtils.createStore; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerSyncIT { + private List errorMessages; + private List exceptions; + private CustomerSync customerSync; + + @BeforeEach + void setup() { + deleteCustomerSyncTestDataFromProjects(); + + createSampleCustomerJohnDoe(CTP_SOURCE_CLIENT); + createSampleCustomerJaneDoe(CTP_SOURCE_CLIENT); + + createSampleCustomerJohnDoe(CTP_TARGET_CLIENT); + + setUpCustomerSync(); + } + + @AfterAll + static void tearDown() { + deleteCustomerSyncTestDataFromProjects(); + } + + private static void deleteCustomerSyncTestDataFromProjects() { + deleteCustomerSyncTestData(CTP_SOURCE_CLIENT); + deleteCustomerSyncTestData(CTP_TARGET_CLIENT); + } + + private void setUpCustomerSync() { + errorMessages = new ArrayList<>(); + exceptions = new ArrayList<>(); + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_TARGET_CLIENT) + .errorCallback((exception, oldResource, newResource, actions) -> { + errorMessages.add(exception.getMessage()); + exceptions.add(exception); + }) + .build(); + customerSync = new CustomerSync(customerSyncOptions); + } + + @Test + void sync_WithoutUpdates_ShouldReturnProperStatistics() { + + final List customers = CTP_SOURCE_CLIENT + .execute(buildCustomerQuery()) + .toCompletableFuture() + .join() + .getResults(); + + final List customerDrafts = mapToCustomerDrafts(customers); + + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(customerDrafts) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(exceptions).isEmpty(); + + assertThat(customerSyncStatistics).hasValues(2, 1, 0, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 2 customers were processed in total (1 created, 0 updated and 0 failed to sync)."); + } + + @Test + void sync_WithUpdates_ShouldReturnProperStatistics() { + + final List customers = CTP_SOURCE_CLIENT + .execute(buildCustomerQuery()) + .toCompletableFuture() + .join() + .getResults(); + + final List updatedCustomerDrafts = prepareUpdatedCustomerDrafts(customers); + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(updatedCustomerDrafts) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(exceptions).isEmpty(); + + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(2, 1, 1, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 2 customers were processed in total (1 created, 1 updated and 0 failed to sync)."); + } + + private List prepareUpdatedCustomerDrafts(@Nonnull final List customers) { + + final Store storeCologne = createStore(CTP_TARGET_CLIENT, "store-cologne"); + + return mapToCustomerDrafts(customers) + .stream() + .map(customerDraft -> + CustomerDraftBuilder + .of(customerDraft) + .plusStores(ResourceIdentifier.ofKey(storeCologne.getKey())) + .custom(CustomFieldsDraft.ofTypeKeyAndJson("customer-type-gold", + createCustomFieldsJsonMap())) + .addresses(singletonList(Address.of(CountryCode.DE).withCity("cologne").withKey("address1"))) + .defaultBillingAddress(0) + .billingAddresses(singletonList(0)) + .defaultShippingAddress(0) + .shippingAddresses(singletonList(0)) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/src/integration-test/java/com/commercetools/sync/integration/externalsource/customers/CustomerSyncIT.java b/src/integration-test/java/com/commercetools/sync/integration/externalsource/customers/CustomerSyncIT.java new file mode 100644 index 0000000000..0963111760 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/externalsource/customers/CustomerSyncIT.java @@ -0,0 +1,278 @@ +package com.commercetools.sync.integration.externalsource.customers; + +import com.commercetools.sync.customers.CustomerSync; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.commercetools.sync.customers.commands.updateactions.AddBillingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.AddShippingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.SetDefaultBillingAddressWithKey; +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.updateactions.AddAddress; +import io.sphere.sdk.customers.commands.updateactions.AddStore; +import io.sphere.sdk.customers.commands.updateactions.ChangeEmail; +import io.sphere.sdk.customers.commands.updateactions.RemoveAddress; +import io.sphere.sdk.customers.commands.updateactions.RemoveBillingAddressId; +import io.sphere.sdk.customers.commands.updateactions.SetCompanyName; +import io.sphere.sdk.customers.commands.updateactions.SetCustomField; +import io.sphere.sdk.customers.commands.updateactions.SetCustomerGroup; +import io.sphere.sdk.customers.commands.updateactions.SetDateOfBirth; +import io.sphere.sdk.customers.commands.updateactions.SetFirstName; +import io.sphere.sdk.customers.commands.updateactions.SetLocale; +import io.sphere.sdk.customers.commands.updateactions.SetMiddleName; +import io.sphere.sdk.customers.commands.updateactions.SetSalutation; +import io.sphere.sdk.customers.commands.updateactions.SetStores; +import io.sphere.sdk.customers.commands.updateactions.SetTitle; +import io.sphere.sdk.customers.commands.updateactions.SetVatId; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.Reference; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.types.CustomFieldsDraft; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import static com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics.assertThat; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.CUSTOMER_NUMBER_EXISTS_WARNING; +import static com.commercetools.sync.integration.commons.utils.CustomerGroupITUtils.createCustomerGroup; +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.createSampleCustomerJohnDoe; +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.deleteCustomerSyncTestData; +import static com.commercetools.sync.integration.commons.utils.ITUtils.BOOLEAN_CUSTOM_FIELD_NAME; +import static com.commercetools.sync.integration.commons.utils.ITUtils.LOCALISED_STRING_CUSTOM_FIELD_NAME; +import static com.commercetools.sync.integration.commons.utils.ITUtils.createCustomFieldsJsonMap; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; +import static com.commercetools.sync.integration.commons.utils.StoreITUtils.createStore; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toMap; +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerSyncIT { + private CustomerDraft customerDraftJohnDoe; + private Customer customerJohnDoe; + + private List errorMessages; + private List warningMessages; + private List exceptions; + private List> updateActionList; + private CustomerSync customerSync; + + @BeforeEach + void setup() { + deleteCustomerSyncTestData(CTP_TARGET_CLIENT); + final ImmutablePair sampleCustomerJohnDoe = + createSampleCustomerJohnDoe(CTP_TARGET_CLIENT); + customerJohnDoe = sampleCustomerJohnDoe.getLeft(); + customerDraftJohnDoe = sampleCustomerJohnDoe.getRight(); + setUpCustomerSync(); + } + + private void setUpCustomerSync() { + errorMessages = new ArrayList<>(); + exceptions = new ArrayList<>(); + warningMessages = new ArrayList<>(); + updateActionList = new ArrayList<>(); + + CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_TARGET_CLIENT) + .errorCallback((exception, oldResource, newResource, actions) -> { + errorMessages.add(exception.getMessage()); + exceptions.add(exception); + }) + .warningCallback((exception, oldResource, newResource) + -> warningMessages.add(exception.getMessage())) + .beforeUpdateCallback((updateActions, customerDraft, customer) -> { + updateActionList.addAll(Objects.requireNonNull(updateActions)); + return updateActions; + }) + .build(); + customerSync = new CustomerSync(customerSyncOptions); + } + + @AfterAll + static void tearDown() { + deleteCustomerSyncTestData(CTP_TARGET_CLIENT); + } + + @Test + void sync_WithSameCustomer_ShouldNotUpdateCustomer() { + final CustomerDraft sameCustomerDraft = CustomerDraftBuilder.of(customerDraftJohnDoe).build(); + + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(sameCustomerDraft)) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(warningMessages).isEmpty(); + assertThat(exceptions).isEmpty(); + assertThat(customerSyncStatistics).hasValues(1, 0, 0, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 1 customers were processed in total (0 created, 0 updated and 0 failed to sync)."); + } + + @Test + void sync_WithNewCustomer_ShouldCreateCustomer() { + final CustomerDraft newCustomerDraft = + CustomerDraftBuilder.of(customerDraftJohnDoe) + .emailVerified(false) + .email("john-2@example.com") + .customerNumber("gold-2") + .key("customer-key-john-doe-2") + .build(); + + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_TARGET_CLIENT) + .build(); + + final CustomerSync customerSync = new CustomerSync(customerSyncOptions); + + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(newCustomerDraft)) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(warningMessages).isEmpty(); + assertThat(exceptions).isEmpty(); + assertThat(updateActionList).isEmpty(); + + assertThat(customerSyncStatistics).hasValues(1, 1, 0, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 1 customers were processed in total (1 created, 0 updated and 0 failed to sync)."); + } + + @Test + void sync_WithUpdatedCustomer_ShouldUpdateCustomer() { + final Store storeCologne = createStore(CTP_TARGET_CLIENT, "store-cologne"); + final CustomerDraft updatedCustomerDraft = + CustomerDraftBuilder.of(customerDraftJohnDoe) + .customerNumber("gold-new") // from gold-1, but can not be changed. + .email("john-new@example.com") //from john@example.com + .stores(asList( // store-cologne is added, store-munich is removed + ResourceIdentifier.ofKey(storeCologne.getKey()), + ResourceIdentifier.ofKey("store-hamburg"), + ResourceIdentifier.ofKey("store-berlin"))) + .build(); + + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(updatedCustomerDraft)) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(warningMessages) + .containsExactly(format(CUSTOMER_NUMBER_EXISTS_WARNING, updatedCustomerDraft.getKey(), "gold-1")); + assertThat(exceptions).isEmpty(); + assertThat(updateActionList).containsExactly( + ChangeEmail.of("john-new@example.com"), + SetStores.of(asList( + ResourceIdentifier.ofKey("store-cologne"), + ResourceIdentifier.ofKey("store-hamburg"), + ResourceIdentifier.ofKey("store-berlin"))) + ); + + assertThat(customerSyncStatistics).hasValues(1, 0, 1, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 1 customers were processed in total (0 created, 1 updated and 0 failed to sync)."); + } + + @Test + void sync_WithUpdatedAllFieldsOfCustomer_ShouldUpdateCustomerWithAllExpectedActions() { + final Store storeCologne = createStore(CTP_TARGET_CLIENT, "store-cologne"); + final CustomerGroup customerGroupSilverMember = + createCustomerGroup(CTP_TARGET_CLIENT, "silver members", "silver"); + + final CustomerDraft updatedCustomerDraft = + CustomerDraftBuilder + .of("jane@example.com", "54321") + .customerNumber("gold-1") // can not be changed after it set. + .key("customer-key-john-doe") + .stores(asList( + ResourceIdentifier.ofKey(storeCologne.getKey()), // new store + ResourceIdentifier.ofKey("store-munich"), + ResourceIdentifier.ofKey("store-hamburg"), + ResourceIdentifier.ofKey("store-berlin"))) + .firstName("Jane") + .lastName("Doe") + .middleName("") + .title("Miss") + .salutation("") + .dateOfBirth(LocalDate.now().minusYears(26)) + .companyName("Acme Corporation 2") + .vatId("DE000000000") + .emailVerified(true) + .customerGroup(ResourceIdentifier.ofKey(customerGroupSilverMember.getKey())) + .addresses(asList( // address2 is removed, address4 is added + Address.of(CountryCode.DE).withCity("berlin").withKey("address1"), + Address.of(CountryCode.DE).withCity("munich").withKey("address3"), + Address.of(CountryCode.DE).withCity("cologne").withKey("address4"))) + .defaultBillingAddress(2) // 0 becomes 2 -> berlin to cologne. + .billingAddresses(singletonList(2)) // 0, 1 becomes 2 -> berlin, hamburg to cologne. + .defaultShippingAddress(1) // 2 becomes 1 -> munich to munich. + .shippingAddresses(asList(0, 1)) // 2 become 0, 1 -> munich to berlin, munich. + .custom(CustomFieldsDraft.ofTypeKeyAndJson("customer-type-gold", + createCustomFieldsJsonMap())) + .locale(Locale.FRENCH) + .build(); + + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(updatedCustomerDraft)) + .toCompletableFuture() + .join(); + + assertThat(errorMessages).isEmpty(); + assertThat(warningMessages).isEmpty(); + assertThat(exceptions).isEmpty(); + + final Map addressKeyToIdMap = + customerJohnDoe.getAddresses().stream().collect(toMap(Address::getKey, Address::getId)); + + assertThat(updateActionList).containsExactly( + ChangeEmail.of("jane@example.com"), + SetFirstName.of("Jane"), + SetMiddleName.of(""), + SetTitle.of("Miss"), + SetSalutation.of(""), + SetCustomerGroup.of(Reference.of(CustomerGroup.referenceTypeId(), customerGroupSilverMember.getId())), + SetCompanyName.of("Acme Corporation 2"), + SetDateOfBirth.of(LocalDate.now().minusYears(26)), + SetVatId.of("DE000000000"), + SetLocale.of(Locale.FRENCH), + RemoveAddress.of(addressKeyToIdMap.get("address2")), + AddAddress.of(Address.of(CountryCode.DE).withCity("cologne").withKey("address4")), + RemoveBillingAddressId.of(addressKeyToIdMap.get("address1")), + SetDefaultBillingAddressWithKey.of("address4"), + AddShippingAddressIdWithKey.of("address1"), + AddBillingAddressIdWithKey.of("address4"), + SetCustomField.ofJson(LOCALISED_STRING_CUSTOM_FIELD_NAME, + JsonNodeFactory.instance.objectNode().put("de", "rot").put("en", "red")), + SetCustomField.ofJson(BOOLEAN_CUSTOM_FIELD_NAME, JsonNodeFactory.instance.booleanNode(false)), + AddStore.of(ResourceIdentifier.ofKey(storeCologne.getKey())) + ); + + assertThat(customerSyncStatistics).hasValues(1, 0, 1, 0); + assertThat(customerSyncStatistics + .getReportMessage()) + .isEqualTo("Summary: 1 customers were processed in total (0 created, 1 updated and 0 failed to sync)."); + } +} diff --git a/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomerServiceImplIT.java b/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomerServiceImplIT.java new file mode 100644 index 0000000000..5ee7bb0f13 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomerServiceImplIT.java @@ -0,0 +1,236 @@ +package com.commercetools.sync.integration.services.impl; + +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.commercetools.sync.services.CustomerService; +import com.commercetools.sync.services.impl.CustomerServiceImpl; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.CustomerCreateCommand; +import io.sphere.sdk.customers.commands.updateactions.ChangeEmail; +import io.sphere.sdk.customers.queries.CustomerQuery; +import io.sphere.sdk.queries.QueryPredicate; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static com.commercetools.sync.integration.commons.utils.CustomerITUtils.deleteCustomers; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; +import static java.lang.String.format; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class CustomerServiceImplIT { + private static final String EXISTING_CUSTOMER_KEY = "existing-customer-key"; + private CustomerService customerService; + private Customer customer; + + + private List errorCallBackMessages; + private List warningCallBackMessages; + private List errorCallBackExceptions; + + /** + * Deletes Customers from target CTP projects, then it populates target CTP project with customer test data. + */ + @BeforeEach + void setupTest() { + errorCallBackMessages = new ArrayList<>(); + errorCallBackExceptions = new ArrayList<>(); + warningCallBackMessages = new ArrayList<>(); + + deleteCustomers(CTP_TARGET_CLIENT); + + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(CTP_TARGET_CLIENT) + .errorCallback( + (exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .warningCallback( + (exception, oldResource, newResource) -> warningCallBackMessages + .add(exception.getMessage())) + .build(); + + // Create a mock new customer in the target project. + CustomerDraft customerDraft = CustomerDraftBuilder + .of("mail@mail.com", "password") + .key(EXISTING_CUSTOMER_KEY) + .build(); + customer = CTP_TARGET_CLIENT.execute(CustomerCreateCommand.of(customerDraft)) + .toCompletableFuture().join().getCustomer(); + + customerService = new CustomerServiceImpl(customerSyncOptions); + } + + /** + * Cleans up the target test data that were built in this test class. + */ + @AfterAll + static void tearDown() { + deleteCustomers(CTP_TARGET_CLIENT); + } + + @Test + void fetchCachedCustomerId_WithNonExistingCustomer_ShouldNotFetchACustomerId() { + final Optional customerId = customerService.fetchCachedCustomerId("non-existing-customer-key") + .toCompletableFuture() + .join(); + assertThat(customerId).isEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void fetchCachedCustomerId_WithExistingNotCachedCustomer_ShouldFetchACustomerId() { + final Optional customerId = customerService.fetchCachedCustomerId(EXISTING_CUSTOMER_KEY) + .toCompletableFuture() + .join(); + assertThat(customerId).isNotEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void fetchCustomerByKey_WithExistingCustomer_ShouldFetchCustomer() { + Optional customer = customerService.fetchCustomerByKey(EXISTING_CUSTOMER_KEY) + .toCompletableFuture() + .join(); + assertThat(customer).isNotEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void fetchCustomerByKey_WithNotExistingCustomer_ShouldReturnEmptyOptional() { + Optional customer = customerService.fetchCustomerByKey("not-existing-customer-key") + .toCompletableFuture() + .join(); + assertThat(customer).isEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void cacheKeysToIds_WithEmptyKeys_ShouldReturnCurrentCache() { + Map cache = customerService.cacheKeysToIds(emptySet()).toCompletableFuture().join(); + assertThat(cache).hasSize(0); + + cache = customerService.cacheKeysToIds(singleton(EXISTING_CUSTOMER_KEY)).toCompletableFuture().join(); + assertThat(cache).hasSize(1); + + cache = customerService.cacheKeysToIds(emptySet()).toCompletableFuture().join(); + assertThat(cache).hasSize(1); + + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void cacheKeysToIds_WithCachedKeys_ShouldReturnCacheWithoutAnyRequests() { + final SphereClient spyClient = spy(CTP_TARGET_CLIENT); + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(spyClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .warningCallback((exception, oldResource, newResource) + -> warningCallBackMessages.add(exception.getMessage())) + .build(); + final CustomerServiceImpl spyCustomerService = new CustomerServiceImpl(customerSyncOptions); + + + Map cache = spyCustomerService.cacheKeysToIds(singleton(EXISTING_CUSTOMER_KEY)) + .toCompletableFuture().join(); + assertThat(cache).hasSize(1); + + cache = spyCustomerService.cacheKeysToIds(singleton(EXISTING_CUSTOMER_KEY)) + .toCompletableFuture().join(); + assertThat(cache).hasSize(1); + + verify(spyClient, times(1)).execute(any()); + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + } + + @Test + void fetchMatchingCustomersByKeys_WithEmptyKeys_ShouldReturnEmptySet() { + Set customers = customerService.fetchMatchingCustomersByKeys(emptySet()) + .toCompletableFuture() + .join(); + + assertThat(customers).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + } + + @Test + void fetchMatchingCustomersByKeys_WithExistingCustomerKeys_ShouldReturnCustomers() { + Set customers = customerService.fetchMatchingCustomersByKeys(singleton(EXISTING_CUSTOMER_KEY)) + .toCompletableFuture() + .join(); + + assertThat(customers).hasSize(1); + assertThat(errorCallBackMessages).isEmpty(); + assertThat(errorCallBackExceptions).isEmpty(); + } + + @Test + void createCustomer_WithDuplicationException_ShouldNotCreateCustomer() { + CustomerDraft customerDraft = CustomerDraftBuilder + .of("mail@mail.com", "password") + .key("newKey") + .build(); + + Optional customerOptional = + customerService.createCustomer(customerDraft).toCompletableFuture().join(); + + assertThat(customerOptional).isEmpty(); + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)).contains("Failed to create draft with key: 'newKey'. Reason: " + + "detailMessage: There is already an existing customer with the email '\"mail@mail.com\"'."); + assertThat(errorCallBackExceptions).hasSize(1); + } + + @Test + void updateCustomer_WithValidChanges_ShouldUpdateCustomerCorrectly() { + final String newEmail = "newMail@newmail.com"; + final ChangeEmail changeEmail = ChangeEmail + .of(newEmail); + + final Customer updatedCustomer = customerService + .updateCustomer(customer, singletonList(changeEmail)) + .toCompletableFuture().join(); + assertThat(updatedCustomer).isNotNull(); + + final Optional queried = CTP_TARGET_CLIENT + .execute(CustomerQuery.of() + .withPredicates(QueryPredicate.of(format("key = \"%s\"", EXISTING_CUSTOMER_KEY)))) + .toCompletableFuture().join().head(); + + assertThat(errorCallBackExceptions).isEmpty(); + assertThat(errorCallBackMessages).isEmpty(); + assertThat(queried).isNotEmpty(); + final Customer fetchedCustomer = queried.get(); + assertThat(fetchedCustomer.getEmail()).isEqualTo(updatedCustomer.getEmail()); + assertThat(fetchedCustomer.getPassword()).isEqualTo(updatedCustomer.getPassword()); + + } + +} diff --git a/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java b/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java index 5593ff6d6c..3b8f7be625 100644 --- a/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java @@ -2,8 +2,6 @@ import com.commercetools.sync.commons.helpers.BaseSyncStatistics; -import static java.lang.String.format; - public final class CartDiscountSyncStatistics extends BaseSyncStatistics { /** @@ -15,10 +13,6 @@ public final class CartDiscountSyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format( - "Summary: %s cart discounts were processed in total (%s created, %s updated and %s failed to sync).", - getProcessed(), getCreated(), getUpdated(), getFailed()); - - return reportMessage; + return getDefaultReportMessageForResource("cart discounts"); } } diff --git a/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java b/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java index 0c8c8087bc..58fa863b38 100644 --- a/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java +++ b/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java @@ -47,10 +47,9 @@ public CategorySyncStatistics() { */ @Override public String getReportMessage() { - reportMessage = format("Summary: %s categories were processed in total " + return format("Summary: %s categories were processed in total " + "(%s created, %s updated, %s failed to sync and %s categories with a missing parent).", getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfCategoriesWithMissingParents()); - return reportMessage; } /** diff --git a/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java b/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java index 03592712af..94828371aa 100644 --- a/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java @@ -2,13 +2,13 @@ import io.netty.util.internal.StringUtil; +import javax.annotation.Nonnull; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static java.lang.String.format; public abstract class BaseSyncStatistics { - protected String reportMessage; private AtomicInteger updated; private AtomicInteger created; private AtomicInteger failed; @@ -31,7 +31,6 @@ public BaseSyncStatistics() { created = new AtomicInteger(); failed = new AtomicInteger(); processed = new AtomicInteger(); - reportMessage = StringUtil.EMPTY_STRING; latestBatchHumanReadableProcessingTime = StringUtil.EMPTY_STRING; } @@ -285,4 +284,18 @@ public long getLatestBatchProcessingTimeInMillis() { * @return a summary message of the statistics report. */ public abstract String getReportMessage(); + + /** + * Builds a proper summary message of the statistics report of a given {@code resourceString} in following format: + * + *

"Summary: 2 resources were processed in total (2 created, 2 updated and 0 failed to sync)." + * + * @param resourceString a string representing the resource + * @return a summary message of the statistics report + */ + protected String getDefaultReportMessageForResource(@Nonnull final String resourceString) { + return format( + "Summary: %s %s were processed in total (%s created, %s updated and %s failed to sync).", + getProcessed(), resourceString, getCreated(), getUpdated(), getFailed()); + } } diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSync.java b/src/main/java/com/commercetools/sync/customers/CustomerSync.java new file mode 100644 index 0000000000..8cf532b638 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/CustomerSync.java @@ -0,0 +1,290 @@ +package com.commercetools.sync.customers; + +import com.commercetools.sync.commons.BaseSync; +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.customers.helpers.CustomerBatchValidator; +import com.commercetools.sync.customers.helpers.CustomerReferenceResolver; +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; +import com.commercetools.sync.services.CustomerGroupService; +import com.commercetools.sync.services.CustomerService; +import com.commercetools.sync.services.TypeService; +import com.commercetools.sync.services.impl.CustomerGroupServiceImpl; +import com.commercetools.sync.services.impl.CustomerServiceImpl; +import com.commercetools.sync.services.impl.TypeServiceImpl; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import org.apache.commons.lang3.tuple.ImmutablePair; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import static com.commercetools.sync.commons.utils.SyncUtils.batchElements; +import static com.commercetools.sync.customers.utils.CustomerSyncUtils.buildActions; +import static java.lang.String.format; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +/** + * This class syncs customer drafts with the corresponding customers in the CTP project. + */ +public class CustomerSync extends BaseSync { + + private static final String CTP_CUSTOMER_FETCH_FAILED = "Failed to fetch existing customers with keys: '%s'."; + private static final String FAILED_TO_PROCESS = "Failed to process the CustomerDraft with key:'%s'. Reason: %s"; + private static final String CTP_CUSTOMER_UPDATE_FAILED = "Failed to update customer with key: '%s'. Reason: %s"; + + private final CustomerService customerService; + private final CustomerReferenceResolver referenceResolver; + private final CustomerBatchValidator batchValidator; + + /** + * Takes a {@link CustomerSyncOptions} to instantiate a new {@link CustomerSync} instance that could be used to + * sync customer drafts in the CTP project specified in the injected {@link CustomerSync} instance. + * + * @param customerSyncOptions the container of all the options of the sync process including the CTP project + * client and/or configuration and other sync-specific options. + */ + public CustomerSync(@Nonnull final CustomerSyncOptions customerSyncOptions) { + this(customerSyncOptions, + new CustomerServiceImpl(customerSyncOptions), + new TypeServiceImpl(customerSyncOptions), + new CustomerGroupServiceImpl(customerSyncOptions)); + } + + /** + * Takes a {@link CustomerSyncOptions} and service instances to instantiate a new {@link CustomerSync} instance + * that could be used to sync customer drafts in the CTP project specified in the injected + * {@link CustomerSyncOptions} instance. + * + *

NOTE: This constructor is mainly to be used for tests where the services can be mocked and passed to. + * + * @param customerSyncOptions the container of all the options of the sync process including the CTP project + * client and/or configuration and other sync-specific options. + * @param customerService the customer service which is responsible for fetching/caching the Customers from the + * CTP project. + * @param typeService the type service which is responsible for fetching/caching the Types from the CTP project. + * @param customerGroupService the customer group service which is responsible for fetching/caching the + * CustomerGroups from the CTP project. + */ + protected CustomerSync(@Nonnull final CustomerSyncOptions customerSyncOptions, + @Nonnull final CustomerService customerService, + @Nonnull final TypeService typeService, + @Nonnull final CustomerGroupService customerGroupService) { + super(new CustomerSyncStatistics(), customerSyncOptions); + this.customerService = customerService; + this.referenceResolver = new CustomerReferenceResolver(getSyncOptions(), typeService, customerGroupService); + this.batchValidator = new CustomerBatchValidator(getSyncOptions(), getStatistics()); + } + + /** + * Iterates through the whole {@code customerDrafts} list and accumulates its valid drafts to batches. + * Every batch is then processed by {@link CustomerSync#processBatch(List)}. + * + *

Inherited doc: + * {@inheritDoc} + * + * @param customerDrafts {@link List} of {@link CustomerDraft}'s that would be synced into CTP project. + * @return {@link CompletionStage} with {@link CustomerSyncStatistics} holding statistics of all sync + * processes performed by this sync instance. + */ + @Override + protected CompletionStage process(@Nonnull final List customerDrafts) { + final List> batches = batchElements(customerDrafts, syncOptions.getBatchSize()); + return syncBatches(batches, completedFuture(statistics)); + + } + + @Override + protected CompletionStage processBatch(@Nonnull final List batch) { + final ImmutablePair, CustomerBatchValidator.ReferencedKeys> result = + batchValidator.validateAndCollectReferencedKeys(batch); + + final Set validCustomerDrafts = result.getLeft(); + if (validCustomerDrafts.isEmpty()) { + statistics.incrementProcessed(batch.size()); + return completedFuture(statistics); + } + + return referenceResolver + .populateKeyToIdCachesForReferencedKeys(result.getRight()) + .handle(ImmutablePair::new) + .thenCompose(cachingResponse -> { + final Throwable cachingException = cachingResponse.getValue(); + if (cachingException != null) { + handleError(new SyncException("Failed to build a cache of keys to ids.", cachingException), + validCustomerDrafts.size()); + return CompletableFuture.completedFuture(null); + } + + final Set validCustomerKeys = + validCustomerDrafts.stream().map(CustomerDraft::getKey).collect(toSet()); + + return customerService + .fetchMatchingCustomersByKeys(validCustomerKeys) + .handle(ImmutablePair::new) + .thenCompose(fetchResponse -> { + final Set fetchedCustomers = fetchResponse.getKey(); + final Throwable exception = fetchResponse.getValue(); + + if (exception != null) { + final String errorMessage = format(CTP_CUSTOMER_FETCH_FAILED, validCustomerKeys); + handleError(new SyncException(errorMessage, exception), validCustomerKeys.size()); + return CompletableFuture.completedFuture(null); + } else { + return syncBatch(fetchedCustomers, validCustomerDrafts); + } + }); + }) + .thenApply(ignoredResult -> { + statistics.incrementProcessed(batch.size()); + return statistics; + }); + } + + @Nonnull + private CompletionStage syncBatch( + @Nonnull final Set oldCustomers, + @Nonnull final Set newCustomerDrafts) { + + final Map oldCustomerMap = oldCustomers + .stream() + .collect(toMap(Customer::getKey, identity())); + + return CompletableFuture.allOf(newCustomerDrafts + .stream() + .map(customerDraft -> + referenceResolver + .resolveReferences(customerDraft) + .thenCompose(resolvedCustomerDraft -> syncDraft(oldCustomerMap, resolvedCustomerDraft)) + .exceptionally(completionException -> { + final String errorMessage = format(FAILED_TO_PROCESS, customerDraft.getKey(), + completionException.getMessage()); + handleError(new SyncException(errorMessage, completionException), 1); + return null; + }) + ) + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + } + + @Nonnull + private CompletionStage syncDraft( + @Nonnull final Map oldCustomerMap, + @Nonnull final CustomerDraft newCustomerDraft) { + + final Customer oldCustomer = oldCustomerMap.get(newCustomerDraft.getKey()); + return Optional.ofNullable(oldCustomer) + .map(customer -> buildActionsAndUpdate(oldCustomer, newCustomerDraft)) + .orElseGet(() -> applyCallbackAndCreate(newCustomerDraft)); + } + + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") // https://github.com/findbugsproject/findbugs/issues/79 + @Nonnull + private CompletionStage buildActionsAndUpdate( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomerDraft) { + + final List> updateActions = + buildActions(oldCustomer, newCustomerDraft, syncOptions); + + final List> updateActionsAfterCallback + = syncOptions.applyBeforeUpdateCallback(updateActions, newCustomerDraft, oldCustomer); + + if (!updateActionsAfterCallback.isEmpty()) { + return updateCustomer(oldCustomer, newCustomerDraft, updateActionsAfterCallback); + } + + return completedFuture(null); + } + + @Nonnull + private CompletionStage updateCustomer( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomerDraft, + @Nonnull final List> updateActionsAfterCallback) { + + return customerService + .updateCustomer(oldCustomer, updateActionsAfterCallback) + .handle(ImmutablePair::of) + .thenCompose(updateResponse -> { + final Throwable exception = updateResponse.getValue(); + if (exception != null) { + return executeSupplierIfConcurrentModificationException( + exception, + () -> fetchAndUpdate(oldCustomer, newCustomerDraft), + () -> { + final String errorMessage = + format(CTP_CUSTOMER_UPDATE_FAILED, newCustomerDraft.getKey(), exception.getMessage()); + handleError(new SyncException(errorMessage, exception), 1); + return CompletableFuture.completedFuture(null); + }); + } else { + statistics.incrementUpdated(); + return CompletableFuture.completedFuture(null); + } + }); + } + + @Nonnull + private CompletionStage fetchAndUpdate( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomerDraft) { + + final String customerKey = oldCustomer.getKey(); + return customerService + .fetchCustomerByKey(customerKey) + .handle(ImmutablePair::of) + .thenCompose(fetchResponse -> { + final Optional fetchedCustomerOptional = fetchResponse.getKey(); + final Throwable exception = fetchResponse.getValue(); + + if (exception != null) { + final String errorMessage = format(CTP_CUSTOMER_UPDATE_FAILED, customerKey, + "Failed to fetch from CTP while retrying after concurrency modification."); + handleError(new SyncException(errorMessage, exception), 1); + return CompletableFuture.completedFuture(null); + } + + return fetchedCustomerOptional + .map(fetchedCustomer -> buildActionsAndUpdate(fetchedCustomer, newCustomerDraft)) + .orElseGet(() -> { + final String errorMessage = format(CTP_CUSTOMER_UPDATE_FAILED, customerKey, + "Not found when attempting to fetch while retrying after concurrency modification."); + handleError(new SyncException(errorMessage, null), 1); + return CompletableFuture.completedFuture(null); + }); + }); + } + + @Nonnull + private CompletionStage applyCallbackAndCreate(@Nonnull final CustomerDraft customerDraft) { + + return syncOptions + .applyBeforeCreateCallback(customerDraft) + .map(draft -> customerService + .createCustomer(draft) + .thenAccept(customerOptional -> { + if (customerOptional.isPresent()) { + statistics.incrementCreated(); + } else { + statistics.incrementFailed(); + } + })) + .orElseGet(() -> CompletableFuture.completedFuture(null)); + } + + private void handleError(@Nonnull final SyncException syncException, + final int failedTimes) { + syncOptions.applyErrorCallback(syncException); + statistics.incrementFailed(failedTimes); + } +} diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java new file mode 100644 index 0000000000..526cce4b8b --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java @@ -0,0 +1,38 @@ +package com.commercetools.sync.customers; + +import com.commercetools.sync.commons.BaseSyncOptions; +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.commons.utils.QuadConsumer; +import com.commercetools.sync.commons.utils.TriConsumer; +import com.commercetools.sync.commons.utils.TriFunction; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +public final class CustomerSyncOptions extends BaseSyncOptions { + + CustomerSyncOptions( + @Nonnull final SphereClient ctpClient, + @Nullable final QuadConsumer, Optional, + List>> errorCallback, + @Nullable final TriConsumer, Optional> + warningCallback, + final int batchSize, + @Nullable final TriFunction>, CustomerDraft, Customer, + List>> beforeUpdateCallback, + @Nullable final Function beforeCreateCallback) { + super(ctpClient, + errorCallback, + warningCallback, + batchSize, + beforeUpdateCallback, + beforeCreateCallback); + } +} diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java new file mode 100644 index 0000000000..a957180f84 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java @@ -0,0 +1,58 @@ +package com.commercetools.sync.customers; + + +import com.commercetools.sync.commons.BaseSyncOptionsBuilder; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; + +import javax.annotation.Nonnull; + +public final class CustomerSyncOptionsBuilder extends BaseSyncOptionsBuilder { + public static final int BATCH_SIZE_DEFAULT = 50; + + private CustomerSyncOptionsBuilder(@Nonnull final SphereClient ctpClient) { + this.ctpClient = ctpClient; + } + + /** + * Creates a new instance of {@link CustomerSyncOptionsBuilder} given a {@link SphereClient} responsible for + * interaction with the target CTP project, with the default batch size ({@code BATCH_SIZE_DEFAULT} = 50). + * + * @param ctpClient instance of the {@link SphereClient} responsible for interaction with the target CTP project. + * @return new instance of {@link CustomerSyncOptionsBuilder} + */ + public static CustomerSyncOptionsBuilder of(@Nonnull final SphereClient ctpClient) { + return new CustomerSyncOptionsBuilder(ctpClient) + .batchSize(BATCH_SIZE_DEFAULT); + } + + /** + * Creates a new instance of {@link CustomerSyncOptions} enriched with all attributes provided to {@code this} + * builder. + * + * @return new instance of {@link CustomerSyncOptions} + */ + @Override + public CustomerSyncOptions build() { + return new CustomerSyncOptions( + ctpClient, + errorCallback, + warningCallback, + batchSize, + beforeUpdateCallback, + beforeCreateCallback); + } + + /** + * Returns an instance of this class to be used in the superclass's generic methods. Please see the JavaDoc in the + * overridden method for further details. + * + * @return an instance of this class. + */ + @Override + protected CustomerSyncOptionsBuilder getThis() { + return this; + } +} diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java new file mode 100644 index 0000000000..ab1d8f6f53 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java @@ -0,0 +1,26 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import io.sphere.sdk.commands.UpdateActionImpl; +import io.sphere.sdk.customers.Customer; + +import javax.annotation.Nonnull; + +// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet. +// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071 +public final class AddBillingAddressIdWithKey extends UpdateActionImpl { + private final String addressKey; + + private AddBillingAddressIdWithKey(@Nonnull final String addressKey) { + super("addBillingAddressId"); + this.addressKey = addressKey; + } + + public static AddBillingAddressIdWithKey of(@Nonnull final String addressKey) { + return new AddBillingAddressIdWithKey(addressKey); + } + + @Nonnull + public String getAddressKey() { + return addressKey; + } +} diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java new file mode 100644 index 0000000000..35a2d683f7 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java @@ -0,0 +1,26 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import io.sphere.sdk.commands.UpdateActionImpl; +import io.sphere.sdk.customers.Customer; + +import javax.annotation.Nonnull; + +// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet. +// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071 +public final class AddShippingAddressIdWithKey extends UpdateActionImpl { + private final String addressKey; + + private AddShippingAddressIdWithKey(@Nonnull final String addressKey) { + super("addShippingAddressId"); + this.addressKey = addressKey; + } + + public static AddShippingAddressIdWithKey of(@Nonnull final String addressKey) { + return new AddShippingAddressIdWithKey(addressKey); + } + + @Nonnull + public String getAddressKey() { + return addressKey; + } +} diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java new file mode 100644 index 0000000000..b57875bbf0 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java @@ -0,0 +1,27 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import io.sphere.sdk.commands.UpdateActionImpl; +import io.sphere.sdk.customers.Customer; + +import javax.annotation.Nullable; + +// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet. +// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071 +public final class SetDefaultBillingAddressWithKey extends UpdateActionImpl { + private final String addressKey; + + private SetDefaultBillingAddressWithKey(@Nullable final String addressKey) { + super("setDefaultBillingAddress"); + this.addressKey = addressKey; + } + + public static SetDefaultBillingAddressWithKey of(@Nullable final String addressKey) { + return new SetDefaultBillingAddressWithKey(addressKey); + } + + @Nullable + public String getAddressKey() { + return addressKey; + } +} + diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java new file mode 100644 index 0000000000..848526bd2d --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java @@ -0,0 +1,27 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import io.sphere.sdk.commands.UpdateActionImpl; +import io.sphere.sdk.customers.Customer; + +import javax.annotation.Nullable; + +// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet. +// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071 +public final class SetDefaultShippingAddressWithKey extends UpdateActionImpl { + private final String addressKey; + + private SetDefaultShippingAddressWithKey(@Nullable final String addressKey) { + super("setDefaultShippingAddress"); + this.addressKey = addressKey; + } + + public static SetDefaultShippingAddressWithKey of(@Nullable final String addressKey) { + return new SetDefaultShippingAddressWithKey(addressKey); + } + + @Nullable + public String getAddressKey() { + return addressKey; + } +} + diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java new file mode 100644 index 0000000000..9bab5bb30d --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java @@ -0,0 +1,229 @@ +package com.commercetools.sync.customers.helpers; + +import com.commercetools.sync.commons.helpers.BaseBatchValidator; +import com.commercetools.sync.customers.CustomerSyncOptions; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.models.Address; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +import org.apache.commons.lang3.tuple.ImmutablePair; + +import static java.lang.String.format; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang3.StringUtils.isBlank; + +public class CustomerBatchValidator + extends BaseBatchValidator { + + public static final String CUSTOMER_DRAFT_IS_NULL = "CustomerDraft is null."; + public static final String CUSTOMER_DRAFT_KEY_NOT_SET = "CustomerDraft with email: %s doesn't have a key. " + + "Please make sure all customer drafts have keys."; + public static final String CUSTOMER_DRAFT_EMAIL_NOT_SET = "CustomerDraft with key: %s doesn't have an email. " + + "Please make sure all customer drafts have emails."; + static final String CUSTOMER_DRAFT_PASSWORD_NOT_SET = "CustomerDraft with key: %s doesn't have a password. " + + "Please make sure all customer drafts have passwords."; + + static final String ADDRESSES_ARE_NULL = "CustomerDraft with key: '%s' has null addresses on indexes: '%s'. " + + "Please make sure each address is set in the addresses list."; + static final String ADDRESSES_THAT_KEYS_NOT_SET = "CustomerDraft with key: '%s' has blank address keys on indexes: " + + "'%s'. Please make sure each address has a key in the addresses list"; + static final String ADDRESSES_THAT_KEYS_NOT_UNIQUE = "CustomerDraft with key: '%s' has duplicate address keys on " + + "indexes: '%s'. Please make sure each address key is unique in the addresses list."; + + static final String BILLING_ADDRESSES_ARE_NOT_VALID = "CustomerDraft with key: '%s' does not contain indices: '%s' " + + "of the 'billingAddresses' in the addresses list. " + + "Please make sure all customer drafts have valid index values for the 'billingAddresses'."; + + static final String SHIPPING_ADDRESSES_ARE_NOT_VALID = "CustomerDraft with key: '%s' does not contain indices: '%s'" + + " of the 'shippingAddresses' in the addresses list. " + + "Please make sure all customer drafts have valid index values for the 'shippingAddresses'."; + + public CustomerBatchValidator(@Nonnull final CustomerSyncOptions syncOptions, + @Nonnull final CustomerSyncStatistics syncStatistics) { + super(syncOptions, syncStatistics); + } + + /** + * Given the {@link List}<{@link CustomerDraft}> of drafts this method attempts to validate + * drafts and return an {@link ImmutablePair}<{@link Set}<{@link CustomerDraft}>, + * {@link CustomerBatchValidator.ReferencedKeys}> which contains the {@link Set} of valid drafts and + * referenced keys within a wrapper. + * + *

A valid customer draft is one which satisfies the following conditions: + *

    + *
  1. It is not null
  2. + *
  3. It has a key which is not blank (null/empty)
  4. + *
  5. It has a email which is not blank (null/empty)
  6. + *
  7. Each address in the addresses list satisfies the following conditions: + *
      + *
    1. It is not null
    2. + *
    3. It has a key which is not blank (null/empty)
    4. + *
    5. It has a unique key
    6. + *
    + *
  8. + *
  9. Each address index in the 'billing' and 'shipping' addresses list are valid and contained + * in the addresses list. + *
+ * + * + * @param customerDrafts the customer drafts to validate and collect referenced keys. + * @return {@link ImmutablePair}<{@link Set}<{@link CustomerDraft}>, + * {@link CustomerBatchValidator.ReferencedKeys}> which contains the {@link Set} of valid drafts and + * referenced keys within a wrapper. + */ + @Override + public ImmutablePair, ReferencedKeys> validateAndCollectReferencedKeys( + @Nonnull final List customerDrafts) { + + final ReferencedKeys referencedKeys = new ReferencedKeys(); + final Set validDrafts = customerDrafts + .stream() + .filter(this::isValidCustomerDraft) + .peek(customerDraft -> collectReferencedKeys(referencedKeys, customerDraft)) + .collect(toSet()); + + return ImmutablePair.of(validDrafts, referencedKeys); + } + + private boolean isValidCustomerDraft(@Nullable final CustomerDraft customerDraft) { + + if (customerDraft == null) { + handleError(CUSTOMER_DRAFT_IS_NULL); + } else if (isBlank(customerDraft.getKey())) { + handleError(format(CUSTOMER_DRAFT_KEY_NOT_SET, customerDraft.getEmail())); + } else if (isBlank(customerDraft.getEmail())) { + handleError(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, customerDraft.getKey())); + } else if (isBlank(customerDraft.getPassword())) { + handleError(format(CUSTOMER_DRAFT_PASSWORD_NOT_SET, customerDraft.getKey())); + } else if (hasValidAddresses(customerDraft)) { + return hasValidBillingAndShippingAddresses(customerDraft); + } + return false; + } + + private boolean hasValidAddresses(@Nonnull final CustomerDraft customerDraft) { + + final List
addressList = customerDraft.getAddresses(); + if (addressList == null || addressList.isEmpty()) { + return true; + } + + final List indexesOfNullAddresses = getIndexes(addressList, Objects::isNull); + if (!indexesOfNullAddresses.isEmpty()) { + handleError(format(ADDRESSES_ARE_NULL, customerDraft.getKey(), indexesOfNullAddresses)); + return false; + } + + final List indexesOfBlankAddressKeys = getIndexes(addressList, address -> isBlank(address.getKey())); + if (!indexesOfBlankAddressKeys.isEmpty()) { + handleError(format(ADDRESSES_THAT_KEYS_NOT_SET, customerDraft.getKey(), indexesOfBlankAddressKeys)); + return false; + } + + final Predicate
searchDuplicateKeys = (Address address) -> + addressList.stream().filter(a -> Objects.equals(a.getKey(), address.getKey())).count() > 1; + + final List indexesOfDuplicateAddressKeys = getIndexes(addressList, searchDuplicateKeys); + if (!indexesOfDuplicateAddressKeys.isEmpty()) { + handleError(format(ADDRESSES_THAT_KEYS_NOT_UNIQUE, customerDraft.getKey(), indexesOfDuplicateAddressKeys)); + return false; + } + + return true; + } + + @Nonnull + private List getIndexes(@Nonnull final List
list, @Nonnull final Predicate
predicate) { + + final List indexes = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + if (predicate.test(list.get(i))) { + indexes.add(i); + } + } + return indexes; + } + + private boolean hasValidBillingAndShippingAddresses(@Nonnull final CustomerDraft customerDraft) { + /* An example error response from the API, when the indexes are not valid: + { + "statusCode": 400, + "message": "Customer does not contain an address at index '1'.", + "errors": [{ + "code": "InvalidOperation", + "message": "Customer does not contain an address at index '-1'." + }, + { + "code": "InvalidOperation", + "message": "Customer does not contain an address at index '1'." + }] + } + */ + + final List
addressList = customerDraft.getAddresses(); + final Predicate isInvalidIndex = index -> index == null || addressList == null + || addressList.isEmpty() || index < 0 || index > addressList.size() - 1; + + if (customerDraft.getBillingAddresses() != null && !customerDraft.getBillingAddresses().isEmpty()) { + final List invalidIndexes = getIndexValues(customerDraft.getBillingAddresses(), isInvalidIndex); + if (!invalidIndexes.isEmpty()) { + handleError(format(BILLING_ADDRESSES_ARE_NOT_VALID, customerDraft.getKey(), invalidIndexes)); + return false; + } + } + + if (customerDraft.getShippingAddresses() != null && !customerDraft.getShippingAddresses().isEmpty()) { + final List invalidIndexes = getIndexValues(customerDraft.getShippingAddresses(), isInvalidIndex); + if (!invalidIndexes.isEmpty()) { + handleError(format(SHIPPING_ADDRESSES_ARE_NOT_VALID, customerDraft.getKey(), invalidIndexes)); + return false; + } + } + + return true; + } + + @Nonnull + private List getIndexValues(@Nonnull final List list, + @Nonnull final Predicate predicate) { + + final List indexes = new ArrayList<>(); + for (Integer integer : list) { + if (predicate.test(integer)) { + indexes.add(integer); + } + } + return indexes; + } + + private void collectReferencedKeys( + @Nonnull final CustomerBatchValidator.ReferencedKeys referencedKeys, + @Nonnull final CustomerDraft customerDraft) { + + collectReferencedKeyFromResourceIdentifier(customerDraft.getCustomerGroup(), + referencedKeys.customerGroupKeys::add); + collectReferencedKeyFromCustomFieldsDraft(customerDraft.getCustom(), + referencedKeys.typeKeys::add); + } + + public static class ReferencedKeys { + private final Set customerGroupKeys = new HashSet<>(); + private final Set typeKeys = new HashSet<>(); + + public Set getTypeKeys() { + return typeKeys; + } + + public Set getCustomerGroupKeys() { + return customerGroupKeys; + } + } +} diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java new file mode 100644 index 0000000000..5d7078fe1a --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java @@ -0,0 +1,203 @@ +package com.commercetools.sync.customers.helpers; + +import com.commercetools.sync.commons.exceptions.ReferenceResolutionException; +import com.commercetools.sync.commons.helpers.CustomReferenceResolver; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.services.CustomerGroupService; +import com.commercetools.sync.services.TypeService; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import static com.commercetools.sync.commons.utils.CompletableFutureUtils.collectionOfFuturesToFutureOfCollection; +import static io.sphere.sdk.utils.CompletableFutureUtils.exceptionallyCompletedFuture; +import static java.lang.String.format; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.stream.Collectors.toList; + +public final class CustomerReferenceResolver + extends CustomReferenceResolver { + + public static final String FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE = + "Failed to resolve customer group resource identifier on CustomerDraft with key:'%s'. Reason: %s"; + public static final String FAILED_TO_RESOLVE_STORE_REFERENCE = + "Failed to resolve store resource identifier on CustomerDraft with key:'%s'. Reason: %s"; + public static final String FAILED_TO_RESOLVE_CUSTOM_TYPE = "Failed to resolve custom type reference on " + + "CustomerDraft with key:'%s'."; + public static final String CUSTOMER_GROUP_DOES_NOT_EXIST = "CustomerGroup with key '%s' doesn't exist."; + + private final TypeService typeService; + private final CustomerGroupService customerGroupService; + + /** + * Takes a {@link CustomerSyncOptions} instance, a {@link TypeService} and a {@link CustomerGroupService} to + * instantiate a {@link CustomerReferenceResolver} instance that could be used to resolve the customer drafts in the + * CTP project specified in the injected {@link CustomerSyncOptions} instance. + * + * @param options the container of all the options of the sync process including the CTP project client + * and/or configuration and other sync-specific options. + * @param typeService the service to fetch the custom types for reference resolution. + * @param customerGroupService the service to fetch the customer groups for reference resolution. + */ + public CustomerReferenceResolver( + @Nonnull final CustomerSyncOptions options, + @Nonnull final TypeService typeService, + @Nonnull final CustomerGroupService customerGroupService) { + super(options, typeService); + this.typeService = typeService; + this.customerGroupService = customerGroupService; + } + + /** + * Given a {@link CustomerDraft} this method attempts to resolve the stores, customer group and custom type + * references to return a {@link CompletionStage} which contains a new instance of the draft with the resolved + * references or, in case an error occurs during reference resolution, a {@link ReferenceResolutionException}. + * + * @param customerDraft the draft to resolve its references. + * @return a {@link CompletionStage} that contains as a result a new CustomerDraft instance with resolved + * custom type reference or, in case an error occurs during reference resolution, + * a {@link ReferenceResolutionException}. + */ + @Override + @Nonnull + public CompletionStage resolveReferences(@Nonnull final CustomerDraft customerDraft) { + return resolveCustomTypeReference(CustomerDraftBuilder.of(customerDraft)) + .thenCompose(this::resolveCustomerGroupReference) + .thenCompose(this::resolveStoreReferences) + .thenApply(CustomerDraftBuilder::build); + } + + @Override + protected CompletionStage resolveCustomTypeReference( + @Nonnull final CustomerDraftBuilder draftBuilder) { + + return resolveCustomTypeReference(draftBuilder, + CustomerDraftBuilder::getCustom, + CustomerDraftBuilder::custom, + format(FAILED_TO_RESOLVE_CUSTOM_TYPE, draftBuilder.getKey())); + } + + /** + * Given a {@link CustomerDraftBuilder} this method attempts to resolve the customer group to return a + * {@link CompletionStage} which contains a new instance of the builder with the resolved customer group reference. + * + * @param draftBuilder the customerDraft to resolve its customer group reference. + * @return a {@link CompletionStage} that contains as a result a new builder instance with resolved customer group + * reference or, in case an error occurs during reference resolution, + * a {@link ReferenceResolutionException}. + */ + @Nonnull + public CompletionStage resolveCustomerGroupReference( + @Nonnull final CustomerDraftBuilder draftBuilder) { + + final ResourceIdentifier customerGroupResourceIdentifier = draftBuilder.getCustomerGroup(); + if (customerGroupResourceIdentifier != null && customerGroupResourceIdentifier.getId() == null) { + String customerGroupKey; + try { + customerGroupKey = getKeyFromResourceIdentifier(customerGroupResourceIdentifier); + } catch (ReferenceResolutionException referenceResolutionException) { + return exceptionallyCompletedFuture(new ReferenceResolutionException( + format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, draftBuilder.getKey(), + referenceResolutionException.getMessage()))); + } + + return fetchAndResolveCustomerGroupReference(draftBuilder, customerGroupKey); + } + return completedFuture(draftBuilder); + } + + @Nonnull + private CompletionStage fetchAndResolveCustomerGroupReference( + @Nonnull final CustomerDraftBuilder draftBuilder, + @Nonnull final String customerGroupKey) { + + return customerGroupService + .fetchCachedCustomerGroupId(customerGroupKey) + .thenCompose(resolvedCustomerGroupIdOptional -> resolvedCustomerGroupIdOptional + .map(resolvedCustomerGroupId -> + completedFuture(draftBuilder.customerGroup( + CustomerGroup.referenceOfId(resolvedCustomerGroupId).toResourceIdentifier()))) + .orElseGet(() -> { + final String errorMessage = format(CUSTOMER_GROUP_DOES_NOT_EXIST, customerGroupKey); + return exceptionallyCompletedFuture(new ReferenceResolutionException( + format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, draftBuilder.getKey(), + errorMessage))); + })); + } + + /** + * Given a {@link CustomerDraftBuilder} this method attempts to resolve the stores and return + * a {@link CompletionStage} which contains a new instance of the builder with the resolved references. + * + * @param draftBuilder the customer draft to resolve its store references. + * @return a {@link CompletionStage} that contains as a result a new builder instance with resolved references or, + * in case an error occurs during reference resolution, a {@link ReferenceResolutionException}. + */ + @Nonnull + public CompletionStage resolveStoreReferences( + @Nonnull final CustomerDraftBuilder draftBuilder) { + + final List> storeResourceIdentifiers = draftBuilder.getStores(); + if (storeResourceIdentifiers == null || storeResourceIdentifiers.isEmpty()) { + return completedFuture(draftBuilder); + } + final List> resolvedReferences = new ArrayList<>(); + for (ResourceIdentifier storeResourceIdentifier : storeResourceIdentifiers) { + if (storeResourceIdentifier != null) { + if (storeResourceIdentifier.getId() == null) { + try { + final String storeKey = getKeyFromResourceIdentifier(storeResourceIdentifier); + resolvedReferences.add(ResourceIdentifier.ofKey(storeKey)); + } catch (ReferenceResolutionException referenceResolutionException) { + return exceptionallyCompletedFuture( + new ReferenceResolutionException( + format(FAILED_TO_RESOLVE_STORE_REFERENCE, + draftBuilder.getKey(), referenceResolutionException.getMessage()))); + } + } else { + resolvedReferences.add(ResourceIdentifier.ofId(storeResourceIdentifier.getId())); + } + } + } + return completedFuture(draftBuilder.stores(resolvedReferences)); + } + + /** + * Calls the {@code cacheKeysToIds} service methods to fetch all the referenced keys + * (i.e custom type, customer group) from the commercetools to populate caches for the reference resolution. + * + *

Note: This method is meant be only used internally by the library to improve performance. + * + * @param referencedKeys a wrapper for the product references to fetch and cache the id's for. + * @return {@link CompletionStage}<{@link Map}<{@link String}>{@link String}>> in which the results + * of it's completions contains a map of requested references keys -> ids of customer references. + */ + @Nonnull + public CompletableFuture>> populateKeyToIdCachesForReferencedKeys( + @Nonnull final CustomerBatchValidator.ReferencedKeys referencedKeys) { + + final List>> futures = new ArrayList<>(); + + final Set typeKeys = referencedKeys.getTypeKeys(); + if (!typeKeys.isEmpty()) { + futures.add(typeService.cacheKeysToIds(typeKeys)); + } + + final Set customerGroupKeys = referencedKeys.getCustomerGroupKeys(); + if (!customerGroupKeys.isEmpty()) { + futures.add(customerGroupService.cacheKeysToIds(customerGroupKeys)); + } + + return collectionOfFuturesToFutureOfCollection(futures, toList()); + } +} diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java new file mode 100644 index 0000000000..c1a4fa93df --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java @@ -0,0 +1,18 @@ +package com.commercetools.sync.customers.helpers; + +import com.commercetools.sync.commons.helpers.BaseSyncStatistics; + +public class CustomerSyncStatistics extends BaseSyncStatistics { + + /** + * Builds a summary of the customer sync statistics instance that looks like the following example: + * + *

"Summary: 2 customers have been processed in total (0 created, 0 updated and 0 failed to sync)." + * + * @return a summary message of the customer sync statistics instance. + */ + @Override + public String getReportMessage() { + return getDefaultReportMessageForResource("customers"); + } +} diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java new file mode 100644 index 0000000000..7ccfe93184 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java @@ -0,0 +1,57 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.commons.helpers.GenericCustomActionBuilder; +import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.commands.updateactions.SetCustomField; +import io.sphere.sdk.customers.commands.updateactions.SetCustomType; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; + +public final class CustomerCustomActionBuilder implements GenericCustomActionBuilder { + + private static final CustomerCustomActionBuilder builder = new CustomerCustomActionBuilder(); + + private CustomerCustomActionBuilder() { + super(); + } + + @Nonnull + public static CustomerCustomActionBuilder of() { + return builder; + } + + @Nonnull + @Override + public UpdateAction buildRemoveCustomTypeAction( + @Nullable final Integer variantId, + @Nullable final String objectId) { + + return SetCustomType.ofRemoveType(); + } + + @Nonnull + @Override + public UpdateAction buildSetCustomTypeAction( + @Nullable final Integer variantId, + @Nullable final String objectId, + @Nonnull final String customTypeId, + @Nullable final Map customFieldsJsonMap) { + + return SetCustomType.ofTypeIdAndJson(customTypeId, customFieldsJsonMap); + } + + @Nonnull + @Override + public UpdateAction buildSetCustomFieldAction( + @Nullable final Integer variantId, + @Nullable final String objectId, + @Nullable final String customFieldName, + @Nullable final JsonNode customFieldValue) { + + return SetCustomField.ofJson(customFieldName, customFieldValue); + } +} diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java new file mode 100644 index 0000000000..1df8d20e7f --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java @@ -0,0 +1,180 @@ +package com.commercetools.sync.customers.utils; + +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.expansion.CustomerExpansionModel; +import io.sphere.sdk.customers.queries.CustomerQuery; +import io.sphere.sdk.expansion.ExpansionPath; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.KeyReference; +import io.sphere.sdk.models.Reference; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.types.Type; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static com.commercetools.sync.commons.utils.CustomTypeReferenceResolutionUtils.mapToCustomFieldsDraft; +import static com.commercetools.sync.commons.utils.SyncUtils.getResourceIdentifierWithKey; +import static java.util.stream.Collectors.toList; +import static org.apache.http.util.TextUtils.isBlank; + +/** + * Util class which provides utilities that can be used when syncing resources from a source commercetools project + * to a target one. + */ +public final class CustomerReferenceResolutionUtils { + + /** + * Returns a {@link List}<{@link CustomerDraft}> consisting of the results of applying the + * mapping from {@link Customer} to {@link CustomerDraft} with considering reference resolution. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Reference fieldfromto
customerGroup{@link Reference}<{@link CustomerGroup}>{@link ResourceIdentifier}<{@link CustomerGroup}>
stores{@link Set}<{@link KeyReference}<{@link Store}>>{@link Set}<{@link ResourceIdentifier}<{@link Store}>>
custom.type{@link Reference}<{@link Type}>{@link ResourceIdentifier}<{@link Type}>
+ * + *

Note: The {@link CustomerGroup} and {@link Type} references should be expanded with a key. + * Any reference that is not expanded will have its id in place and not replaced by the key will be + * considered as existing resources on the target commercetools project and + * the library will issues an update/create API request without reference resolution. + * + * @param customers the customers with expanded references. + * @return a {@link List} of {@link CustomerDraft} built from the supplied {@link List} of {@link Customer}. + */ + @Nonnull + public static List mapToCustomerDrafts( + @Nonnull final List customers) { + return customers + .stream() + .map(CustomerReferenceResolutionUtils::mapToCustomerDraft) + .collect(toList()); + } + + @Nonnull + private static CustomerDraft mapToCustomerDraft(@Nonnull final Customer customer) { + return CustomerDraftBuilder + .of(customer.getEmail(), customer.getPassword()) + .customerNumber(customer.getCustomerNumber()) + .key(customer.getKey()) + .firstName(customer.getFirstName()) + .lastName(customer.getLastName()) + .middleName(customer.getMiddleName()) + .title(customer.getTitle()) + .externalId(customer.getExternalId()) + .companyName(customer.getCompanyName()) + .customerGroup(getResourceIdentifierWithKey(customer.getCustomerGroup())) + .dateOfBirth(customer.getDateOfBirth()) + .isEmailVerified(customer.isEmailVerified()) + .vatId(customer.getVatId()) + .addresses(customer.getAddresses()) + .defaultBillingAddress(getAddressIndex(customer.getAddresses(), customer.getDefaultBillingAddressId())) + .billingAddresses(getAddressIndexList(customer.getAddresses(), customer.getBillingAddressIds())) + .defaultShippingAddress(getAddressIndex(customer.getAddresses(), customer.getDefaultShippingAddressId())) + .shippingAddresses(getAddressIndexList(customer.getAddresses(), customer.getShippingAddressIds())) + .custom(mapToCustomFieldsDraft(customer)) + .locale(customer.getLocale()) + .salutation(customer.getSalutation()) + .stores(mapToStores(customer)) + .build(); + } + + + @Nullable + private static Integer getAddressIndex( + @Nullable final List

allAddresses, + @Nullable final String addressId) { + + if (allAddresses == null) { + return null; + } + if (isBlank(addressId)) { + return null; + } + for (int i = 0; i < allAddresses.size(); i++) { + String id = allAddresses.get(i).getId(); + if (id != null && id.equals(addressId)) { + return i; + } + } + return null; + } + + @Nullable + private static List getAddressIndexList( + @Nullable final List
allAddresses, + @Nullable final List addressIds) { + if (allAddresses == null || addressIds == null) { + return null; + } + final List indexes = new ArrayList<>(); + for (String addressId : addressIds) { + indexes.add(getAddressIndex(allAddresses, addressId)); + } + return indexes; + } + + @Nullable + private static List> mapToStores(@Nonnull final Customer customer) { + final List> storeReferences = customer.getStores(); + if (storeReferences != null) { + return storeReferences + .stream() + .map(storeKeyReference -> ResourceIdentifier.ofKey(storeKeyReference.getKey())) + .collect(toList()); + } + return null; + } + + /** + * Builds a {@link CustomerQuery} for fetching customers from a source CTP project with all the needed + * references expanded for the sync: + *
    + *
  • Custom Type
  • + *
  • CustomerGroup
  • + *
+ * + *

Note: Please only use this util if you desire to sync all the aforementioned references from + * a source commercetools project. Otherwise, it is more efficient to build the query without expansions, if they + * are not needed, to avoid unnecessarily bigger payloads fetched from the source project. + * + * @return the query for fetching customers from the source CTP project with all the aforementioned references + * expanded. + */ + public static CustomerQuery buildCustomerQuery() { + return CustomerQuery.of() + .withExpansionPaths(CustomerExpansionModel::customerGroup) + .plusExpansionPaths(ExpansionPath.of("custom.type")); + } + + private CustomerReferenceResolutionUtils() { + } +} diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java new file mode 100644 index 0000000000..97ea1e287a --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java @@ -0,0 +1,92 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.customers.CustomerSyncOptions; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; + +import javax.annotation.Nonnull; +import java.util.List; + +import static com.commercetools.sync.commons.utils.CustomUpdateActionUtils.buildPrimaryResourceCustomUpdateActions; +import static com.commercetools.sync.commons.utils.OptionalUtils.filterEmptyOptionals; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAllAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildChangeEmailUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCompanyNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerGroupUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerNumberUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetDateOfBirthUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetExternalIdUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetFirstNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLastNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLocaleUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetMiddleNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetSalutationUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetTitleUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetVatIdUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildStoreUpdateActions; + +public final class CustomerSyncUtils { + + private static final CustomerCustomActionBuilder customerCustomActionBuilder = CustomerCustomActionBuilder.of(); + + /** + * Compares all the fields of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of + * {@link UpdateAction}<{@link Customer}> as a result. If no update action is needed, for example in + * case where both the {@link CustomerDraft} and the {@link CustomerDraft} have the same fields, an empty + * {@link List} is returned. + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new data. + * @param syncOptions the sync options wrapper which contains options related to the sync process supplied + * by the user. For example, custom callbacks to call in case of warnings or errors occurring + * on the build update action process. And other options (See {@link CustomerSyncOptions} + * for more info. + * @return A list of customer specific update actions. + */ + @Nonnull + public static List> buildActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer, + @Nonnull final CustomerSyncOptions syncOptions) { + + final List> updateActions = filterEmptyOptionals( + buildChangeEmailUpdateAction(oldCustomer, newCustomer), + buildSetFirstNameUpdateAction(oldCustomer, newCustomer), + buildSetLastNameUpdateAction(oldCustomer, newCustomer), + buildSetMiddleNameUpdateAction(oldCustomer, newCustomer), + buildSetTitleUpdateAction(oldCustomer, newCustomer), + buildSetSalutationUpdateAction(oldCustomer, newCustomer), + buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer), + buildSetCustomerNumberUpdateAction(oldCustomer, newCustomer, syncOptions), + buildSetExternalIdUpdateAction(oldCustomer, newCustomer), + buildSetCompanyNameUpdateAction(oldCustomer, newCustomer), + buildSetDateOfBirthUpdateAction(oldCustomer, newCustomer), + buildSetVatIdUpdateAction(oldCustomer, newCustomer), + buildSetLocaleUpdateAction(oldCustomer, newCustomer) + ); + + final List> addressUpdateActions = + buildAllAddressUpdateActions(oldCustomer, newCustomer); + + updateActions.addAll(addressUpdateActions); + + final List> customerCustomUpdateActions = + buildPrimaryResourceCustomUpdateActions(oldCustomer, + newCustomer, + customerCustomActionBuilder, + syncOptions); + + updateActions.addAll(customerCustomUpdateActions); + + final List> buildStoreUpdateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + updateActions.addAll(buildStoreUpdateActions); + + return updateActions; + } + + private CustomerSyncUtils() { + } +} diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java new file mode 100644 index 0000000000..e650cd96ea --- /dev/null +++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java @@ -0,0 +1,1043 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.commands.updateactions.AddBillingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.AddShippingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.SetDefaultBillingAddressWithKey; +import com.commercetools.sync.customers.commands.updateactions.SetDefaultShippingAddressWithKey; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.commands.updateactions.AddAddress; +import io.sphere.sdk.customers.commands.updateactions.AddStore; +import io.sphere.sdk.customers.commands.updateactions.ChangeAddress; +import io.sphere.sdk.customers.commands.updateactions.ChangeEmail; +import io.sphere.sdk.customers.commands.updateactions.RemoveAddress; +import io.sphere.sdk.customers.commands.updateactions.RemoveBillingAddressId; +import io.sphere.sdk.customers.commands.updateactions.RemoveShippingAddressId; +import io.sphere.sdk.customers.commands.updateactions.RemoveStore; +import io.sphere.sdk.customers.commands.updateactions.SetCompanyName; +import io.sphere.sdk.customers.commands.updateactions.SetCustomerGroup; +import io.sphere.sdk.customers.commands.updateactions.SetCustomerNumber; +import io.sphere.sdk.customers.commands.updateactions.SetDateOfBirth; +import io.sphere.sdk.customers.commands.updateactions.SetExternalId; +import io.sphere.sdk.customers.commands.updateactions.SetFirstName; +import io.sphere.sdk.customers.commands.updateactions.SetLastName; +import io.sphere.sdk.customers.commands.updateactions.SetLocale; +import io.sphere.sdk.customers.commands.updateactions.SetMiddleName; +import io.sphere.sdk.customers.commands.updateactions.SetSalutation; +import io.sphere.sdk.customers.commands.updateactions.SetStores; +import io.sphere.sdk.customers.commands.updateactions.SetTitle; +import io.sphere.sdk.customers.commands.updateactions.SetVatId; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.KeyReference; +import io.sphere.sdk.models.Reference; +import io.sphere.sdk.models.Referenceable; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.models.ResourceImpl; +import io.sphere.sdk.stores.Store; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.commercetools.sync.commons.utils.CommonTypeUpdateActionUtils.buildUpdateAction; +import static com.commercetools.sync.commons.utils.CommonTypeUpdateActionUtils.buildUpdateActionForReferences; +import static java.lang.String.format; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang3.StringUtils.isBlank; + +public final class CustomerUpdateActionUtils { + + public static final String CUSTOMER_NUMBER_EXISTS_WARNING = "Customer with key: \"%s\" has " + + "already a customer number: \"%s\", once it's set it cannot be changed. " + + "Hereby, the update action is not created."; + + private CustomerUpdateActionUtils() { + } + + /** + * Compares the {@code email} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "changeEmail"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code email} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new email. + * @return optional containing update action or empty optional if emails are identical. + */ + @Nonnull + public static Optional> buildChangeEmailUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getEmail(), newCustomer.getEmail(), + () -> ChangeEmail.of(newCustomer.getEmail())); + } + + /** + * Compares the {@code firstName} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setFirstName"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code firstName} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new first name. + * @return optional containing update action or empty optional if first names are identical. + */ + @Nonnull + public static Optional> buildSetFirstNameUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getFirstName(), newCustomer.getFirstName(), + () -> SetFirstName.of(newCustomer.getFirstName())); + } + + /** + * Compares the {@code lastName} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setLastName"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code lastName} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new last name. + * @return optional containing update action or empty optional if last names are identical. + */ + @Nonnull + public static Optional> buildSetLastNameUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getLastName(), newCustomer.getLastName(), + () -> SetLastName.of(newCustomer.getLastName())); + } + + /** + * Compares the {@code middleName} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setMiddleName"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code middleName} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new middle name. + * @return optional containing update action or empty optional if middle names are identical. + */ + @Nonnull + public static Optional> buildSetMiddleNameUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getMiddleName(), newCustomer.getMiddleName(), + () -> SetMiddleName.of(newCustomer.getMiddleName())); + } + + /** + * Compares the {@code title} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setTitle"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code title} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new title. + * @return optional containing update action or empty optional if titles are identical. + */ + @Nonnull + public static Optional> buildSetTitleUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getTitle(), newCustomer.getTitle(), + () -> SetTitle.of(newCustomer.getTitle())); + } + + /** + * Compares the {@code salutation} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "SetSalutation"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code salutation} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the Customer that should be updated. + * @param newCustomer the Customer draft that contains the new salutation. + * @return optional containing update action or empty optional if salutations are identical. + */ + @Nonnull + public static Optional> buildSetSalutationUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getSalutation(), newCustomer.getSalutation(), + () -> SetSalutation.of(newCustomer.getSalutation())); + } + + /** + * Compares the {@code customerNumber} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setCustomerNumber"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code customerNumber} values, then no update action is needed and empty optional will be returned. + * + *

Note: Customer number should be unique across a project. Once it's set it cannot be changed. For this case, + * warning callback will be triggered and an empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new customer number. + * @param syncOptions responsible for supplying the sync options to the sync utility method. It is used for + * triggering the warning callback when trying to change an existing customer number. + * @return optional containing update action or empty optional if customer numbers are identical. + */ + @Nonnull + public static Optional> buildSetCustomerNumberUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer, + @Nonnull final CustomerSyncOptions syncOptions) { + + final Optional> setCustomerNumberAction = + buildUpdateAction(oldCustomer.getCustomerNumber(), newCustomer.getCustomerNumber(), + () -> SetCustomerNumber.of(newCustomer.getCustomerNumber())); + + if (setCustomerNumberAction.isPresent() && !isBlank(oldCustomer.getCustomerNumber())) { + + syncOptions.applyWarningCallback( + new SyncException(format(CUSTOMER_NUMBER_EXISTS_WARNING, oldCustomer.getKey(), + oldCustomer.getCustomerNumber())), oldCustomer, newCustomer); + + return Optional.empty(); + } + + return setCustomerNumberAction; + } + + /** + * Compares the {@code externalId} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setExternalId"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code externalId} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new external id. + * @return optional containing update action or empty optional if external ids are identical. + */ + @Nonnull + public static Optional> buildSetExternalIdUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getExternalId(), newCustomer.getExternalId(), + () -> SetExternalId.of(newCustomer.getExternalId())); + } + + /** + * Compares the {@code companyName} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setCompanyName"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code companyName} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new company name. + * @return optional containing update action or empty optional if company names are identical. + */ + @Nonnull + public static Optional> buildSetCompanyNameUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getCompanyName(), newCustomer.getCompanyName(), + () -> SetCompanyName.of(newCustomer.getCompanyName())); + } + + /** + * Compares the {@code dateOfBirth} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setDateOfBirth"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code dateOfBirth} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new date of birth. + * @return optional containing update action or empty optional if dates of birth are identical. + */ + @Nonnull + public static Optional> buildSetDateOfBirthUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getDateOfBirth(), newCustomer.getDateOfBirth(), + () -> SetDateOfBirth.of(newCustomer.getDateOfBirth())); + } + + /** + * Compares the {@code vatId} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setVatId"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code vatId} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new vat id. + * @return optional containing update action or empty optional if vat ids are identical. + */ + @Nonnull + public static Optional> buildSetVatIdUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getVatId(), newCustomer.getVatId(), + () -> SetVatId.of(newCustomer.getVatId())); + } + + /** + * Compares the {@code locale} values of a {@link Customer} and a {@link CustomerDraft} + * and returns an {@link Optional} of update action, which would contain the {@code "setLocale"} + * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same + * {@code locale} values, then no update action is needed and empty optional will be returned. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft that contains the new locale. + * @return optional containing update action or empty optional if locales are identical. + */ + @Nonnull + public static Optional> buildSetLocaleUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateAction(oldCustomer.getLocale(), newCustomer.getLocale(), + () -> SetLocale.of(newCustomer.getLocale())); + } + + /** + * Compares the {@link CustomerGroup} references of an old {@link Customer} and + * new {@link CustomerDraft}. If they are different - return {@link SetCustomerGroup} update action. + * + *

If the old value is set, but the new one is empty - the command will unset the customer group. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft with new {@link CustomerGroup} reference. + * @return An optional with {@link SetCustomerGroup} update action. + */ + @Nonnull + public static Optional> buildSetCustomerGroupUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + return buildUpdateActionForReferences(oldCustomer.getCustomerGroup(), newCustomer.getCustomerGroup(), + () -> SetCustomerGroup.of(mapResourceIdentifierToReferenceable(newCustomer.getCustomerGroup()))); + } + + @Nullable + private static Referenceable mapResourceIdentifierToReferenceable( + @Nullable final ResourceIdentifier resourceIdentifier) { + + if (resourceIdentifier == null) { + return null; // unset + } + + // TODO (JVM-SDK), see: SUPPORT-10261 SetCustomerGroup could be created with a ResourceIdentifier + // https://github.com/commercetools/commercetools-jvm-sdk/issues/2072 + return new ResourceImpl(null, null, null, null) { + @Override + public Reference toReference() { + return Reference.of(CustomerGroup.referenceTypeId(), resourceIdentifier.getId()); + } + }; + } + + /** + * Compares the stores of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of + * {@link UpdateAction}<{@link Customer}> as a result. If no update action is needed, for example in + * case where both the {@link Customer} and the {@link CustomerDraft} have the identical stores, an empty + * {@link List} is returned. + * + *

Note: Null values of the stores are filtered out. + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new data. + * @return A list of customer store-related update actions. + */ + @Nonnull + public static List> buildStoreUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + final List> oldStores = oldCustomer.getStores(); + final List> newStores = newCustomer.getStores(); + + return buildSetStoreUpdateAction(oldStores, newStores) + .map(Collections::singletonList) + .orElseGet(() -> prepareStoreActions(oldStores, newStores)); + } + + private static List> prepareStoreActions( + @Nullable final List> oldStores, + @Nullable final List> newStores) { + + if (oldStores != null && newStores != null) { + final List> removeStoreUpdateActions = + buildRemoveStoreUpdateActions(oldStores, newStores); + + final List> addStoreUpdateActions = + buildAddStoreUpdateActions(oldStores, newStores); + + if (!removeStoreUpdateActions.isEmpty() && !addStoreUpdateActions.isEmpty()) { + return buildSetStoreUpdateAction(newStores) + .map(Collections::singletonList) + .orElseGet(Collections::emptyList); + } + + return removeStoreUpdateActions.isEmpty() ? addStoreUpdateActions : removeStoreUpdateActions; + } + + return emptyList(); + } + + /** + * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s + * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link SetStores} update action as a result. + * If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores, then no update actions + * are needed and hence an empty {@link List} is returned. + * + *

Note: Null values of the stores are filtered out. + * + * @param oldStores the stores which should be updated. + * @param newStores the stores where we get the new store. + * @return A list containing the update actions or an empty list if the store references are identical. + */ + @Nonnull + private static Optional> buildSetStoreUpdateAction( + @Nullable final List> oldStores, + @Nullable final List> newStores) { + + if (oldStores != null && !oldStores.isEmpty()) { + if (newStores == null || newStores.isEmpty()) { + return Optional.of(SetStores.of(emptyList())); + } + } else if (newStores != null && !newStores.isEmpty()) { + return buildSetStoreUpdateAction(newStores); + } + + return Optional.empty(); + } + + private static Optional> buildSetStoreUpdateAction( + @Nonnull final List> newStores) { + + final List> stores = + newStores.stream() + .filter(Objects::nonNull) + .collect(toList()); + + if (!stores.isEmpty()) { + return Optional.of(SetStores.of(stores)); + } + + return Optional.empty(); + } + + /** + * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s + * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link List} of {@link RemoveStore} update + * actions as a result, if the old store needs to be removed from a customer to have the same set of stores as + * the new customer. If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores, + * then no update actions are needed and hence an empty {@link List} is returned. + * + *

Note: Null values of the stores are filtered out. + * + * @param oldStores the stores which should be updated. + * @param newStores the stores where we get the new store. + * @return A list containing the update actions or an empty list if the store references are identical. + */ + @Nonnull + public static List> buildRemoveStoreUpdateActions( + @Nonnull final List> oldStores, + @Nonnull final List> newStores) { + + final Map> newStoreKeyToStoreMap = + newStores.stream() + .filter(Objects::nonNull) + .filter(storeResourceIdentifier -> storeResourceIdentifier.getKey() != null) + .collect(toMap(ResourceIdentifier::getKey, identity())); + + return oldStores + .stream() + .filter(Objects::nonNull) + .filter(storeKeyReference -> !newStoreKeyToStoreMap.containsKey(storeKeyReference.getKey())) + .map(storeKeyReference -> RemoveStore.of(ResourceIdentifier.ofKey(storeKeyReference.getKey()))) + .collect(toList()); + } + + /** + * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s + * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link List} of {@link AddStore} update actions + * as a result, if the old store needs to be added to a customer to have the same set of stores as the new customer. + * If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores, then no update actions + * are needed and hence an empty {@link List} is returned. + * + *

Note: Null values of the stores are filtered out. + * + * @param oldStores the stores which should be updated. + * @param newStores the stores where we get the new store. + * @return A list containing the update actions or an empty list if the store references are identical. + */ + @Nonnull + public static List> buildAddStoreUpdateActions( + @Nonnull final List> oldStores, + @Nonnull final List> newStores) { + + final Map> oldStoreKeyToStoreMap = + oldStores.stream() + .filter(Objects::nonNull) + .collect(toMap(KeyReference::getKey, identity())); + + return newStores + .stream() + .filter(Objects::nonNull) + .filter(storeResourceIdentifier -> !oldStoreKeyToStoreMap.containsKey(storeResourceIdentifier.getKey())) + .map(AddStore::of) + .collect(toList()); + } + + /** + * Compares the addresses of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of + * {@link UpdateAction}<{@link Customer}> as a result. If both the {@link Customer} and the + * {@link CustomerDraft} have the same set of addresses, then no update actions are needed and hence an empty + * {@link List} is returned. + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new data. + * @return A list of customer address-related update actions. + */ + @Nonnull + public static List> buildAllAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + final List> addressActions = new ArrayList<>(); + + final List> removeAddressActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + addressActions.addAll(removeAddressActions); + addressActions.addAll(buildChangeAddressUpdateActions(oldCustomer, newCustomer)); + addressActions.addAll(buildAddAddressUpdateActions(oldCustomer, newCustomer)); + + addressActions.addAll( + collectAndFilterRemoveShippingAndBillingActions(removeAddressActions, oldCustomer, newCustomer)); + + buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer).ifPresent(addressActions::add); + buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer).ifPresent(addressActions::add); + + addressActions.addAll(buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)); + addressActions.addAll(buildAddBillingAddressUpdateActions(oldCustomer, newCustomer)); + + return addressActions; + } + + @Nonnull + private static List> collectAndFilterRemoveShippingAndBillingActions( + @Nonnull final List> removeAddressActions, + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + /* An action combination like below will cause a bad request error in API, so we need to filter out + to avoid such cases: + + { + "version": 1, + "actions": [ + { + "action" : "removeAddress", + "addressId": "-FWSGZNy" + }, + { + "action" : "removeBillingAddressId", + "addressId" : "-FWSGZNy" + } + ] + } + + { + "statusCode": 400, + "message": "The customers billingAddressIds don't contain id '-FWSGZNy'.", + "errors": [ + { + "code": "InvalidOperation", + "message": "The customers billingAddressIds don't contain id '-FWSGZNy'.", + "action": { + "action": "removeBillingAddressId", + "addressId": "-FWSGZNy" + }, + "actionIndex": 2 + } + ] + } + */ + final Set addressIdsToRemove = + removeAddressActions.stream() + .map(customerUpdateAction -> (RemoveAddress)customerUpdateAction) + .map(RemoveAddress::getAddressId) + .collect(toSet()); + + + final List> removeActions = new ArrayList<>(); + + removeActions.addAll(buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer) + .stream() + .map(customerUpdateAction -> (RemoveShippingAddressId)customerUpdateAction) + .filter(action -> !addressIdsToRemove.contains(action.getAddressId())) + .collect(toList())); + + removeActions.addAll(buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer) + .stream() + .map(customerUpdateAction -> (RemoveBillingAddressId)customerUpdateAction) + .filter(action -> !addressIdsToRemove.contains(action.getAddressId())) + .collect(toList())); + + return removeActions; + } + + /** + * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}. + * It returns a {@link List} of {@link RemoveAddress} update actions as a result, if the old address needs to be + * removed from the {@code oldCustomer} to have the same set of addresses as the {@code newCustomer}. + * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses, + * then no update actions are needed and hence an empty {@link List} is returned. + * + *

Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Null values of the new addresses are filtered out.
  • + *
  • Address values without keys are filtered out.
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new addresses. + * @return A list containing the update actions or an empty list if the addresses are identical. + */ + @Nonnull + public static List> buildRemoveAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if (oldCustomer.getAddresses().isEmpty()) { + return Collections.emptyList(); + } + + if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) { + + return oldCustomer.getAddresses() + .stream() + .map(address -> RemoveAddress.of(address.getId())) + .collect(Collectors.toList()); + } + + final Set newAddressKeys = + newCustomer.getAddresses() + .stream() + .filter(Objects::nonNull) + .filter(newAddress -> !isBlank(newAddress.getKey())) + .map(Address::getKey) + .collect(toSet()); + + return oldCustomer.getAddresses() + .stream() + .filter(oldAddress -> isBlank(oldAddress.getKey()) + || !newAddressKeys.contains(oldAddress.getKey())) + .map(RemoveAddress::of) + .collect(toList()); + } + + /** + * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}. + * It returns a {@link List} of {@link ChangeAddress} update actions as a result, if the old address needs to be + * changed/updated from the {@code oldCustomer} to have the same set of addresses as the {@code newCustomer}. + * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses, + * then no update actions are needed and hence an empty {@link List} is returned. + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Null values of the new addresses are filtered out.
  • + *
  • Address values without keys are filtered out.
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new addresses. + * @return A list containing the update actions or an empty list if the addresses are identical. + */ + @Nonnull + public static List> buildChangeAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) { + return Collections.emptyList(); + } + + final Map oldAddressKeyToAddressMap = + oldCustomer.getAddresses() + .stream() + .filter(address -> !isBlank(address.getKey())) + .collect(toMap(Address::getKey, identity())); + + return newCustomer.getAddresses() + .stream() + .filter(Objects::nonNull) + .filter(newAddress -> !isBlank(newAddress.getKey())) + .filter(newAddress -> oldAddressKeyToAddressMap.containsKey(newAddress.getKey())) + .map(newAddress -> { + final Address oldAddress = oldAddressKeyToAddressMap.get(newAddress.getKey()); + if (!newAddress.equalsIgnoreId(oldAddress)) { + return ChangeAddress.of(oldAddress.getId(), newAddress); + } + return null; + }) + .filter(Objects::nonNull) + .collect(toList()); + } + + /** + * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}. + * It returns a {@link List} of {@link AddAddress} update actions as a result, if the new address needs to be + * added to have the same set of addresses as the {@code newCustomer}. + * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses, + * then no update actions are needed and hence an empty {@link List} is returned. + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Null values of the new addresses are filtered out.
  • + *
  • Address values without keys are filtered out.
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new addresses. + * @return A list containing the update actions or an empty list if the addresses are identical. + */ + @Nonnull + public static List> buildAddAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) { + return Collections.emptyList(); + } + + final Map oldAddressKeyToAddressMap = + oldCustomer.getAddresses() + .stream() + .filter(address -> !isBlank(address.getKey())) + .collect(toMap(Address::getKey, identity())); + + return newCustomer.getAddresses() + .stream() + .filter(Objects::nonNull) + .filter(newAddress -> !isBlank(newAddress.getKey())) + .filter(newAddress -> !oldAddressKeyToAddressMap.containsKey(newAddress.getKey())) + .map(AddAddress::of) + .collect(toList()); + } + + /** + * Compares the {@link Customer#getDefaultShippingAddress()} and {@link CustomerDraft#getDefaultShippingAddress()}. + * If they are different - return {@link SetDefaultShippingAddressWithKey} update action. If the old shipping + * address is set, but the new one is empty - the command will unset the default shipping address. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft with new default shipping address. + * @return An optional with {@link SetDefaultShippingAddressWithKey} update action. + */ + @Nonnull + public static Optional> buildSetDefaultShippingAddressUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + final Address oldAddress = oldCustomer.getDefaultShippingAddress(); + final String newAddressKey = + getAddressKeyAt(newCustomer.getAddresses(), newCustomer.getDefaultShippingAddress()); + + if (newAddressKey != null) { + if (oldAddress == null || !Objects.equals(oldAddress.getKey(), newAddressKey)) { + return Optional.of(SetDefaultShippingAddressWithKey.of(newAddressKey)); + } + } else if (oldAddress != null) { // unset + return Optional.of(SetDefaultShippingAddressWithKey.of(null)); + } + + return Optional.empty(); + } + + /** + * Compares the {@link Customer#getDefaultBillingAddress()} and {@link CustomerDraft#getDefaultBillingAddress()}. + * If they are different - return {@link SetDefaultShippingAddressWithKey} update action. If the old billing address + * id value is set, but the new one is empty - the command will unset the default billing address. + * + * @param oldCustomer the customer that should be updated. + * @param newCustomer the customer draft with new default billing address. + * @return An optional with {@link SetDefaultShippingAddressWithKey} update action. + */ + @Nonnull + public static Optional> buildSetDefaultBillingAddressUpdateAction( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + final Address oldAddress = oldCustomer.getDefaultBillingAddress(); + final String newAddressKey = + getAddressKeyAt(newCustomer.getAddresses(), newCustomer.getDefaultBillingAddress()); + + if (newAddressKey != null) { + if (oldAddress == null || !Objects.equals(oldAddress.getKey(), newAddressKey)) { + return Optional.of(SetDefaultBillingAddressWithKey.of(newAddressKey)); + } + } else if (oldAddress != null) { // unset + return Optional.of(SetDefaultBillingAddressWithKey.of(null)); + } + + return Optional.empty(); + } + + @Nullable + private static String getAddressKeyAt( + @Nullable final List
    addressList, + @Nullable final Integer index) { + + if (index == null) { + return null; + } + + if (addressList == null || index < 0 || index >= addressList.size()) { + throw new IllegalArgumentException( + format("Addresses list does not contain an address at the index: %s", index)); + } + + final Address address = addressList.get(index); + if (address == null) { + throw new IllegalArgumentException( + format("Address is null at the index: %s of the addresses list.", index)); + } else if (isBlank(address.getKey())) { + throw new IllegalArgumentException( + format("Address does not have a key at the index: %s of the addresses list.", index)); + } else { + return address.getKey(); + } + } + + /** + * Compares the {@link List} of a {@link Customer#getShippingAddresses()} and a + * {@link CustomerDraft#getShippingAddresses()}. It returns a {@link List} of {@link AddShippingAddressIdWithKey} + * update actions as a result, if the new shipping address needs to be added to have the same set of addresses as + * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of + * shipping addresses, then no update actions are needed and hence an empty {@link List} is returned. + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Old address values without keys are filtered out.
  • + *
  • Each address in the new addresses list satisfies the following conditions: + *
      + *
    1. It is not null
    2. + *
    3. It has a key which is not blank (null/empty)
    4. + *
    + * Otherwise, a {@link IllegalArgumentException} will be thrown. + *
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new shipping addresses. + * @return A list containing the update actions or an empty list if the shipping addresses are identical. + */ + @Nonnull + public static List> buildAddShippingAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if (newCustomer.getShippingAddresses() == null + || newCustomer.getShippingAddresses().isEmpty()) { + return Collections.emptyList(); + } + + final Map oldAddressKeyToAddressMap = + oldCustomer.getShippingAddresses() + .stream() + .filter(address -> !isBlank(address.getKey())) + .collect(toMap(Address::getKey, identity())); + + final Set newAddressKeys = + newCustomer.getShippingAddresses() + .stream() + .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index)) + .collect(toSet()); + + return newAddressKeys + .stream() + .filter(newAddressKey -> !oldAddressKeyToAddressMap.containsKey(newAddressKey)) + .map(AddShippingAddressIdWithKey::of) + .collect(toList()); + } + + /** + * Compares the {@link List} of a {@link Customer#getShippingAddresses()} and a + * {@link CustomerDraft#getShippingAddresses()}. It returns a {@link List} of {@link RemoveShippingAddressId} + * update actions as a result, if the old shipping address needs to be removed to have the same set of addresses as + * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of + * shipping addresses, then no update actions are needed and hence an empty {@link List} is returned. + * + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Old shipping addresses without keys will be removed.
  • + *
  • Each address in the new addresses list satisfies the following conditions: + *
      + *
    1. It exists in the given index.
    2. + *
    3. It has a key which is not blank (null/empty)
    4. + *
    + * Otherwise, a {@link IllegalArgumentException} will be thrown. + *
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new shipping addresses. + * @return A list containing the update actions or an empty list if the shipping addresses are identical. + */ + @Nonnull + public static List> buildRemoveShippingAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if ((oldCustomer.getShippingAddresses() == null + || oldCustomer.getShippingAddresses().isEmpty())) { + + return Collections.emptyList(); + } + + if (newCustomer.getShippingAddresses() == null + || newCustomer.getShippingAddresses().isEmpty()) { + + return oldCustomer.getShippingAddresses() + .stream() + .map(address -> RemoveShippingAddressId.of(address.getId())) + .collect(Collectors.toList()); + } + + final Set newAddressKeys = + newCustomer.getShippingAddresses() + .stream() + .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index)) + .collect(toSet()); + + return oldCustomer.getShippingAddresses() + .stream() + .filter(address -> isBlank(address.getKey()) || !newAddressKeys.contains(address.getKey())) + .map(address -> RemoveShippingAddressId.of(address.getId())) + .collect(toList()); + } + + /** + * Compares the {@link List} of a {@link Customer#getBillingAddresses()} and a + * {@link CustomerDraft#getBillingAddresses()}. It returns a {@link List} of {@link AddBillingAddressIdWithKey} + * update actions as a result, if the new billing address needs to be added to have the same set of addresses as + * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of + * billing addresses, then no update actions are needed and hence an empty {@link List} is returned. + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Old address values without keys are filtered out.
  • + *
  • Each address in the new addresses list satisfies the following conditions: + *
      + *
    1. It is not null
    2. + *
    3. It has a key which is not blank (null/empty)
    4. + *
    + * Otherwise, a {@link IllegalArgumentException} will be thrown. + *
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new billing addresses. + * @return A list containing the update actions or an empty list if the billing addresses are identical. + */ + @Nonnull + public static List> buildAddBillingAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if (newCustomer.getBillingAddresses() == null + || newCustomer.getBillingAddresses().isEmpty()) { + return Collections.emptyList(); + } + + final Map oldAddressKeyToAddressMap = + oldCustomer.getBillingAddresses() + .stream() + .filter(address -> !isBlank(address.getKey())) + .collect(toMap(Address::getKey, identity())); + + + final Set newAddressKeys = + newCustomer.getBillingAddresses() + .stream() + .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index)) + .collect(toSet()); + + return newAddressKeys + .stream() + .filter(newAddressKey -> !oldAddressKeyToAddressMap.containsKey(newAddressKey)) + .map(AddBillingAddressIdWithKey::of) + .collect(toList()); + } + + /** + * Compares the {@link List} of a {@link Customer#getBillingAddresses()} and a + * {@link CustomerDraft#getBillingAddresses()}. It returns a {@link List} of {@link RemoveBillingAddressId} + * update actions as a result, if the old billing address needs to be removed to have the same set of addresses as + * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of + * billing addresses, then no update actions are needed and hence an empty {@link List} is returned. + * + *

    Notes: + *

  • Addresses are matching by their keys.
  • + *
  • Null values of the old addresses are filtered out.
  • + *
  • Old shipping address values without keys are filtered out.
  • + *
  • Each address in the new addresses list satisfies the following conditions: + *
      + *
    1. It exists in the given index.
    2. + *
    3. It has a key which is not blank (null/empty)
    4. + *
    + * Otherwise, a {@link IllegalArgumentException} will be thrown. + *
  • + * + * @param oldCustomer the customer which should be updated. + * @param newCustomer the customer draft where we get the new shipping addresses. + * @return A list containing the update actions or an empty list if the shipping addresses are identical. + */ + @Nonnull + public static List> buildRemoveBillingAddressUpdateActions( + @Nonnull final Customer oldCustomer, + @Nonnull final CustomerDraft newCustomer) { + + if ((oldCustomer.getBillingAddresses() == null + || oldCustomer.getBillingAddresses().isEmpty())) { + + return Collections.emptyList(); + } + + if (newCustomer.getBillingAddresses() == null + || newCustomer.getBillingAddresses().isEmpty()) { + + return oldCustomer.getBillingAddresses() + .stream() + .map(address -> RemoveBillingAddressId.of(address.getId())) + .collect(Collectors.toList()); + } + + final Set newAddressKeys = + newCustomer.getBillingAddresses() + .stream() + .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index)) + .collect(toSet()); + + return oldCustomer.getBillingAddresses() + .stream() + .filter(address -> isBlank(address.getKey()) || !newAddressKeys.contains(address.getKey())) + .map(address -> RemoveBillingAddressId.of(address.getId())) + .collect(toList()); + } +} diff --git a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java index 3950da3c1c..0374dc47b1 100644 --- a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java @@ -2,8 +2,6 @@ import com.commercetools.sync.commons.helpers.BaseSyncStatistics; -import static java.lang.String.format; - public class CustomObjectSyncStatistics extends BaseSyncStatistics { /** * Builds a summary of the custom object sync statistics instance that looks like the following example: @@ -14,10 +12,6 @@ public class CustomObjectSyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format( - "Summary: %s custom objects were processed in total (%s created, %s updated and %s failed to sync).", - getProcessed(), getCreated(), getUpdated(), getFailed()); - - return reportMessage; + return getDefaultReportMessageForResource("custom objects"); } } diff --git a/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java b/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java index 964fa803af..af74d57274 100644 --- a/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java +++ b/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java @@ -2,8 +2,6 @@ import com.commercetools.sync.commons.helpers.BaseSyncStatistics; -import static java.lang.String.format; - public class InventorySyncStatistics extends BaseSyncStatistics { public InventorySyncStatistics() { @@ -20,9 +18,6 @@ public InventorySyncStatistics() { */ @Override public String getReportMessage() { - reportMessage = format("Summary: %s inventory entries were processed in total " - + "(%s created, %s updated and %s failed to sync).", - getProcessed(), getCreated(), getUpdated(), getFailed()); - return reportMessage; + return getDefaultReportMessageForResource("inventory entries"); } } diff --git a/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java b/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java index 1383d99217..a288435af8 100644 --- a/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java @@ -38,10 +38,9 @@ public class ProductSyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format("Summary: %s product(s) were processed in total " + return format("Summary: %s product(s) were processed in total " + "(%s created, %s updated, %s failed to sync and %s product(s) with missing reference(s)).", getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfProductsWithMissingParents()); - return reportMessage; } /** diff --git a/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java b/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java index 362f6af413..d7ceaf3698 100644 --- a/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java @@ -72,14 +72,12 @@ public ProductTypeSyncStatistics() { */ @Override public String getReportMessage() { - reportMessage = format( + return format( "Summary: %s product types were processed in total (%s created, %s updated, %s failed to sync" + " and %s product types with at least one NestedType or a Set of NestedType attribute definition(s)" + " referencing a missing product type).", getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfProductTypesWithMissingNestedProductTypes()); - - return reportMessage; } /** diff --git a/src/main/java/com/commercetools/sync/services/CustomerService.java b/src/main/java/com/commercetools/sync/services/CustomerService.java new file mode 100644 index 0000000000..cfbc6af398 --- /dev/null +++ b/src/main/java/com/commercetools/sync/services/CustomerService.java @@ -0,0 +1,106 @@ +package com.commercetools.sync.services; + +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletionStage; + +public interface CustomerService { + + /** + * Given a set of keys this method caches in-memory a mapping of key -> id only for those keys which are not + * already cached. + * + * @param keysToCache a set of keys to cache. + * @return a map of key to ids of the requested keys. + */ + @Nonnull + CompletionStage> cacheKeysToIds(@Nonnull Set keysToCache); + + /** + * Given a {@link Set} of customer keys, this method fetches a set of all the customers, matching the given set of + * keys in the CTP project, defined in an injected {@link SphereClient}. A mapping of the key to the id of the + * fetched customers is persisted in an in-memory map. + * + * @param customerKeys set of customer keys to fetch matching resources by. + * @return {@link CompletionStage}<{@link Set}<{@link Customer}>> in which the result of it's completion + * contains a {@link Set} of all matching customers. + */ + @Nonnull + CompletionStage> fetchMatchingCustomersByKeys(@Nonnull Set customerKeys); + + /** + * Given a customer key, this method fetches a customer that matches this given key in the CTP project defined in a + * potentially injected {@link SphereClient}. If there is no matching resource an empty {@link Optional} will be + * returned in the returned future. A mapping of the key to the id of the fetched customer is persisted in an in + * -memory map. + * + * @param key the key of the resource to fetch + * @return {@link CompletionStage}<{@link Optional}> in which the result of it's completion contains an {@link + * Optional} that contains the matching {@link Customer} if exists, otherwise empty. + */ + @Nonnull + CompletionStage> fetchCustomerByKey(@Nullable String key); + + /** + * Given a {@code key}, if it is blank (null/empty), a completed future with an empty optional is returned. + * Otherwise this method checks if the cached map of resource keys -> ids contains the given key. If it does, an + * optional containing the matching id is returned. If the cache doesn't contain the key; this method attempts to + * fetch the id of the key from the CTP project, caches it and returns a {@link CompletionStage}< {@link + * Optional}<{@link String}>> in which the result of it's completion could contain an {@link Optional} + * holding the id or an empty {@link Optional} if no customer was found in the CTP project with this key. + * + * @param key the key by which a customer id should be fetched from the CTP project. + * @return {@link CompletionStage}<{@link Optional}<{@link String}>> in which the result of it's + * completion could contain an {@link Optional} holding the id or an empty {@link Optional} if no customer + * was found in the CTP project with this key. + */ + @Nonnull + CompletionStage> fetchCachedCustomerId(@Nonnull String key); + + /** + * Given a resource draft of type {@link CustomerDraft}, this method attempts to create a resource {@link Customer} + * based on the draft, in the CTP project defined by the sync options. + * + *

    A completion stage containing an empty option and the error callback will be triggered in those cases: + *

      + *
    • the draft has a blank key
    • + *
    • the create request fails on CTP
    • + *
    + * + *

    On the other hand, if the resource gets created successfully on CTP, the created resource's id and + * key are cached and the method returns a {@link CompletionStage} in which the result of it's completion + * contains an instance {@link Optional} of the resource which was created. + * + * @param customerDraft the resource draft to create a resource based off of. + * @return a {@link CompletionStage} containing an optional with the created resource if successful otherwise an + * empty optional. + */ + @Nonnull + CompletionStage> createCustomer(@Nonnull CustomerDraft customerDraft); + + /** + * Given a {@link Customer} and a {@link List}<{@link UpdateAction}<{@link Customer}>>, this method + * issues an update request with these update actions on this {@link Customer} in the CTP project defined in a + * potentially injected {@link SphereClient}. This method returns {@link CompletionStage}<{@link Customer}> in + * which the result of it's completion contains an instance of the {@link Customer} which was updated in the CTP + * project. + * + * @param customer the {@link Customer} to update. + * @param updateActions the update actions to update the {@link Customer} with. + * @return {@link CompletionStage}<{@link Customer}> containing as a result of it's completion an instance of + * the {@link Customer} which was updated in the CTP project or a {@link io.sphere.sdk.models.SphereException}. + */ + @Nonnull + CompletionStage updateCustomer(@Nonnull Customer customer, + @Nonnull List> updateActions); + +} \ No newline at end of file diff --git a/src/main/java/com/commercetools/sync/services/impl/BaseService.java b/src/main/java/com/commercetools/sync/services/impl/BaseService.java index ac4f8e85e5..986b5629ca 100644 --- a/src/main/java/com/commercetools/sync/services/impl/BaseService.java +++ b/src/main/java/com/commercetools/sync/services/impl/BaseService.java @@ -50,7 +50,7 @@ abstract class BaseService, S extends BaseSyncOp final Map keyToIdCache = new ConcurrentHashMap<>(); private static final int MAXIMUM_ALLOWED_UPDATE_ACTIONS = 500; - private static final String CREATE_FAILED = "Failed to create draft with key: '%s'. Reason: %s"; + static final String CREATE_FAILED = "Failed to create draft with key: '%s'. Reason: %s"; BaseService(@Nonnull final S syncOptions) { this.syncOptions = syncOptions; @@ -256,7 +256,7 @@ CompletionStage> fetchMatchingResources( /** * Given a resource key, this method fetches a resource that matches this given key in the CTP project defined in a * potentially injected {@link SphereClient}. If there is no matching resource an empty {@link Optional} will be - * returned in the returned future. A mapping of the key to the id of the fetched category is persisted in an in + * returned in the returned future. A mapping of the key to the id of the fetched resource is persisted in an in * -memory map. * * @param key the key of the resource to fetch diff --git a/src/main/java/com/commercetools/sync/services/impl/CustomerServiceImpl.java b/src/main/java/com/commercetools/sync/services/impl/CustomerServiceImpl.java new file mode 100644 index 0000000000..195cc5522f --- /dev/null +++ b/src/main/java/com/commercetools/sync/services/impl/CustomerServiceImpl.java @@ -0,0 +1,120 @@ +package com.commercetools.sync.services.impl; + +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.services.CustomerService; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.commands.CustomerCreateCommand; +import io.sphere.sdk.customers.commands.CustomerUpdateCommand; +import io.sphere.sdk.customers.expansion.CustomerExpansionModel; +import io.sphere.sdk.customers.queries.CustomerQuery; +import io.sphere.sdk.customers.queries.CustomerQueryBuilder; +import io.sphere.sdk.customers.queries.CustomerQueryModel; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import static java.lang.String.format; +import static org.apache.commons.lang3.StringUtils.isBlank; + + +public final class CustomerServiceImpl extends BaseServiceWithKey> implements CustomerService { + + public CustomerServiceImpl(@Nonnull final CustomerSyncOptions syncOptions) { + super(syncOptions); + } + + @Nonnull + @Override + public CompletionStage> cacheKeysToIds( + @Nonnull final Set keysToCache) { + return cacheKeysToIds(keysToCache, keysNotCached -> CustomerQueryBuilder + .of() + .plusPredicates(customerQueryModel -> customerQueryModel.key().isIn(keysNotCached)) + .build()); + } + + @Nonnull + @Override + public CompletionStage> fetchMatchingCustomersByKeys( + @Nonnull final Set customerKeys) { + return fetchMatchingResources(customerKeys, () -> CustomerQueryBuilder + .of() + .plusPredicates(customerQueryModel -> customerQueryModel.key().isIn(customerKeys)) + .build()); + } + + @Nonnull + @Override + public CompletionStage> fetchCustomerByKey(@Nullable final String key) { + return fetchResource(key, () -> CustomerQueryBuilder + .of() + .plusPredicates(customerQueryModel -> customerQueryModel.key().is(key)) + .build()); + } + + @Nonnull + @Override + public CompletionStage> fetchCachedCustomerId(@Nonnull final String key) { + return fetchCachedResourceId(key, () -> CustomerQueryBuilder + .of() + .plusPredicates(customerQueryModel -> customerQueryModel.key().is(key)) + .build()); + } + + @Nonnull + @Override + public CompletionStage> createCustomer( + @Nonnull final CustomerDraft customerDraft) { + + // Uses a different implementation than in the base service because CustomerCreateCommand uses a + // different library as CTP responds with a CustomerSignInResult which is not extending resource but a + // different model, containing the customer resource. + final String draftKey = customerDraft.getKey(); + final CustomerCreateCommand createCommand = CustomerCreateCommand.of(customerDraft); + + if (isBlank(draftKey)) { + syncOptions.applyErrorCallback( + new SyncException(format(CREATE_FAILED, draftKey, "Draft key is blank!")), + null, customerDraft, null); + return CompletableFuture.completedFuture(Optional.empty()); + } else { + return syncOptions + .getCtpClient() + .execute(createCommand) + .handle(((resource, exception) -> { + if (exception == null && resource.getCustomer() != null) { + keyToIdCache.put(draftKey, resource.getCustomer().getId()); + return Optional.of(resource.getCustomer()); + } else if (exception != null) { + syncOptions.applyErrorCallback( + new SyncException(format(CREATE_FAILED, draftKey, exception.getMessage()), + exception), + null, customerDraft, null); + return Optional.empty(); + } else { + return Optional.empty(); + } + })); + } + + } + + @Nonnull + @Override + public CompletionStage updateCustomer(@Nonnull final Customer customer, + @Nonnull final + List> updateActions) { + return updateResource(customer, CustomerUpdateCommand::of, updateActions); + } + +} \ No newline at end of file diff --git a/src/main/java/com/commercetools/sync/states/helpers/StateSyncStatistics.java b/src/main/java/com/commercetools/sync/states/helpers/StateSyncStatistics.java index 8af8751926..64df1ed3af 100644 --- a/src/main/java/com/commercetools/sync/states/helpers/StateSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/states/helpers/StateSyncStatistics.java @@ -38,10 +38,9 @@ public class StateSyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format("Summary: %s state(s) were processed in total " + return format("Summary: %s state(s) were processed in total " + "(%s created, %s updated, %s failed to sync and %s state(s) with missing transition(s)).", getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfStatesWithMissingParents()); - return reportMessage; } /** diff --git a/src/main/java/com/commercetools/sync/taxcategories/helpers/TaxCategorySyncStatistics.java b/src/main/java/com/commercetools/sync/taxcategories/helpers/TaxCategorySyncStatistics.java index 5d72a498ce..7ff751c573 100644 --- a/src/main/java/com/commercetools/sync/taxcategories/helpers/TaxCategorySyncStatistics.java +++ b/src/main/java/com/commercetools/sync/taxcategories/helpers/TaxCategorySyncStatistics.java @@ -2,8 +2,6 @@ import com.commercetools.sync.commons.helpers.BaseSyncStatistics; -import static java.lang.String.format; - /** * Tax category sync statistics. * Keeps track of processed, created, updated and failed states through whole sync process. @@ -19,10 +17,7 @@ public final class TaxCategorySyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format( - "Summary: %s tax categories were processed in total (%s created, %s updated and %s failed to sync).", - getProcessed(), getCreated(), getUpdated(), getFailed()); - return reportMessage; + return getDefaultReportMessageForResource("tax categories"); } } diff --git a/src/main/java/com/commercetools/sync/types/helpers/TypeSyncStatistics.java b/src/main/java/com/commercetools/sync/types/helpers/TypeSyncStatistics.java index b14d5fd1e7..4e19b0e5ec 100644 --- a/src/main/java/com/commercetools/sync/types/helpers/TypeSyncStatistics.java +++ b/src/main/java/com/commercetools/sync/types/helpers/TypeSyncStatistics.java @@ -2,8 +2,6 @@ import com.commercetools.sync.commons.helpers.BaseSyncStatistics; -import static java.lang.String.format; - public class TypeSyncStatistics extends BaseSyncStatistics { /** * Builds a summary of the type sync statistics instance that looks like the following example: @@ -14,10 +12,6 @@ public class TypeSyncStatistics extends BaseSyncStatistics { */ @Override public String getReportMessage() { - reportMessage = format( - "Summary: %s types were processed in total (%s created, %s updated and %s failed to sync).", - getProcessed(), getCreated(), getUpdated(), getFailed()); - - return reportMessage; + return getDefaultReportMessageForResource("types"); } } diff --git a/src/test/java/com/commercetools/sync/commons/asserts/statistics/AssertionsForStatistics.java b/src/test/java/com/commercetools/sync/commons/asserts/statistics/AssertionsForStatistics.java index e7b8e7d6de..9a5987fb4a 100644 --- a/src/test/java/com/commercetools/sync/commons/asserts/statistics/AssertionsForStatistics.java +++ b/src/test/java/com/commercetools/sync/commons/asserts/statistics/AssertionsForStatistics.java @@ -2,6 +2,7 @@ import com.commercetools.sync.cartdiscounts.helpers.CartDiscountSyncStatistics; import com.commercetools.sync.categories.helpers.CategorySyncStatistics; +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; import com.commercetools.sync.customobjects.helpers.CustomObjectSyncStatistics; import com.commercetools.sync.inventories.helpers.InventorySyncStatistics; import com.commercetools.sync.products.helpers.ProductSyncStatistics; @@ -115,4 +116,15 @@ public static TaxCategorySyncStatisticsAssert assertThat(@Nullable final TaxCate public static CustomObjectSyncStatisticsAssert assertThat(@Nullable final CustomObjectSyncStatistics statistics) { return new CustomObjectSyncStatisticsAssert(statistics); } + + /** + * Create assertion for {@link CustomerSyncStatistics}. + * + * @param statistics the actual value. + * @return the created assertion object. + */ + @Nonnull + public static CustomerSyncStatisticsAssert assertThat(@Nullable final CustomerSyncStatistics statistics) { + return new CustomerSyncStatisticsAssert(statistics); + } } diff --git a/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomerSyncStatisticsAssert.java b/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomerSyncStatisticsAssert.java new file mode 100644 index 0000000000..91ed32f61d --- /dev/null +++ b/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomerSyncStatisticsAssert.java @@ -0,0 +1,13 @@ +package com.commercetools.sync.commons.asserts.statistics; + +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; + +import javax.annotation.Nullable; + +public final class CustomerSyncStatisticsAssert extends + AbstractSyncStatisticsAssert { + + CustomerSyncStatisticsAssert(@Nullable final CustomerSyncStatistics actual) { + super(actual, CustomerSyncStatisticsAssert.class); + } +} diff --git a/src/test/java/com/commercetools/sync/commons/helpers/BaseSyncStatisticsTest.java b/src/test/java/com/commercetools/sync/commons/helpers/BaseSyncStatisticsTest.java index 3c67dd73f0..d9e236aa5d 100644 --- a/src/test/java/com/commercetools/sync/commons/helpers/BaseSyncStatisticsTest.java +++ b/src/test/java/com/commercetools/sync/commons/helpers/BaseSyncStatisticsTest.java @@ -109,4 +109,11 @@ void calculateProcessingTime_ShouldSetProcessingTimeInAllUnitsAndHumanReadableSt assertThat(baseSyncStatistics.getLatestBatchHumanReadableProcessingTime()) .contains(format(", %dms", remainingMillis)); } + + @Test + void getDefaultReportMessageForResource_withResourceString_ShouldBuildCorrectSummary() { + String message = baseSyncStatistics.getDefaultReportMessageForResource("resources"); + assertThat(message).isEqualTo("Summary: 0 resources were processed in total (0 created, 0 updated and 0 " + + "failed to sync)."); + } } diff --git a/src/test/java/com/commercetools/sync/commons/utils/CustomerCustomUpdateActionUtilsTest.java b/src/test/java/com/commercetools/sync/commons/utils/CustomerCustomUpdateActionUtilsTest.java new file mode 100644 index 0000000000..164b87466d --- /dev/null +++ b/src/test/java/com/commercetools/sync/commons/utils/CustomerCustomUpdateActionUtilsTest.java @@ -0,0 +1,58 @@ +package com.commercetools.sync.commons.utils; + +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.commercetools.sync.customers.utils.CustomerCustomActionBuilder; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.commands.updateactions.SetCustomField; +import io.sphere.sdk.customers.commands.updateactions.SetCustomType; +import java.util.HashMap; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +import static com.commercetools.sync.commons.asserts.actions.AssertionsForUpdateActions.assertThat; +import static io.sphere.sdk.models.ResourceIdentifier.ofId; +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CustomerCustomUpdateActionUtilsTest { + + @Test + void buildTypedSetCustomTypeUpdateAction_WithCustomerResource_ShouldBuildCustomerUpdateAction() { + final String newCustomTypeId = UUID.randomUUID().toString(); + + final UpdateAction updateAction = + GenericUpdateActionUtils.buildTypedSetCustomTypeUpdateAction(newCustomTypeId, new HashMap<>(), + mock(Customer.class), CustomerCustomActionBuilder.of(), null, Customer::getId, + customerResource -> customerResource.toReference().getTypeId(), customerResource -> null, + CustomerSyncOptionsBuilder.of(mock(SphereClient.class)).build()).orElse(null); + + assertThat(updateAction).isInstanceOf(SetCustomType.class); + assertThat((SetCustomType) updateAction).hasValues("setCustomType", emptyMap(), ofId(newCustomTypeId)); + } + + @Test + void buildRemoveCustomTypeAction_WithCustomerResource_ShouldBuildCustomerUpdateAction() { + final UpdateAction updateAction = + CustomerCustomActionBuilder.of().buildRemoveCustomTypeAction(null, null); + + assertThat(updateAction).isInstanceOf(SetCustomType.class); + assertThat((SetCustomType) updateAction).hasValues("setCustomType", null, ofId(null)); + } + + @Test + void buildSetCustomFieldAction_WithCustomerResource_ShouldBuildCustomerUpdateAction() { + final JsonNode customFieldValue = JsonNodeFactory.instance.textNode("foo"); + final String customFieldName = "name"; + + final UpdateAction updateAction = CustomerCustomActionBuilder.of() + .buildSetCustomFieldAction(null, null, customFieldName, customFieldValue); + + assertThat(updateAction).isInstanceOf(SetCustomField.class); + assertThat((SetCustomField) updateAction).hasValues("setCustomField", customFieldName, customFieldValue); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilderTest.java b/src/test/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilderTest.java new file mode 100644 index 0000000000..264d95d04e --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilderTest.java @@ -0,0 +1,262 @@ +package com.commercetools.sync.customers; + +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.commons.utils.QuadConsumer; +import com.commercetools.sync.commons.utils.TriConsumer; +import com.commercetools.sync.commons.utils.TriFunction; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.updateactions.ChangeEmail; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CustomerSyncOptionsBuilderTest { + + private static final SphereClient CTP_CLIENT = mock(SphereClient.class); + private final CustomerSyncOptionsBuilder customerSyncOptionsBuilder = CustomerSyncOptionsBuilder.of(CTP_CLIENT); + + @Test + void of_WithClient_ShouldCreateCustomerSyncOptionsBuilder() { + final CustomerSyncOptionsBuilder builder = CustomerSyncOptionsBuilder.of(CTP_CLIENT); + assertThat(builder).isNotNull(); + } + + @Test + void build_WithClient_ShouldBuildSyncOptions() { + final CustomerSyncOptions customerSyncOptions = customerSyncOptionsBuilder.build(); + assertThat(customerSyncOptions).isNotNull(); + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNull(); + assertThat(customerSyncOptions.getBeforeCreateCallback()).isNull(); + assertThat(customerSyncOptions.getErrorCallback()).isNull(); + assertThat(customerSyncOptions.getWarningCallback()).isNull(); + assertThat(customerSyncOptions.getCtpClient()).isEqualTo(CTP_CLIENT); + assertThat(customerSyncOptions.getBatchSize()).isEqualTo(CustomerSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + } + + @Test + void beforeUpdateCallback_WithFilterAsCallback_ShouldSetCallback() { + final TriFunction>, CustomerDraft, Customer, List>> + beforeUpdateCallback = (updateActions, newCustomer, oldCustomer) -> emptyList(); + + customerSyncOptionsBuilder.beforeUpdateCallback(beforeUpdateCallback); + + final CustomerSyncOptions customerSyncOptions = customerSyncOptionsBuilder.build(); + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNotNull(); + } + + @Test + void beforeCreateCallback_WithFilterAsCallback_ShouldSetCallback() { + customerSyncOptionsBuilder.beforeCreateCallback((newCustomer) -> null); + + final CustomerSyncOptions customerSyncOptions = customerSyncOptionsBuilder.build(); + assertThat(customerSyncOptions.getBeforeCreateCallback()).isNotNull(); + } + + @Test + void errorCallBack_WithCallBack_ShouldSetCallBack() { + final QuadConsumer, Optional, List>> + mockErrorCallBack = (exception, newResource, oldResource, updateActions) -> { }; + customerSyncOptionsBuilder.errorCallback(mockErrorCallBack); + + final CustomerSyncOptions customerSyncOptions = customerSyncOptionsBuilder.build(); + assertThat(customerSyncOptions.getErrorCallback()).isNotNull(); + } + + @Test + void warningCallBack_WithCallBack_ShouldSetCallBack() { + final TriConsumer, Optional> mockWarningCallBack = + (exception, newResource, oldResource) -> { + }; + customerSyncOptionsBuilder.warningCallback(mockWarningCallBack); + + final CustomerSyncOptions customerSyncOptions = customerSyncOptionsBuilder.build(); + assertThat(customerSyncOptions.getWarningCallback()).isNotNull(); + } + + @Test + void getThis_ShouldReturnCorrectInstance() { + final CustomerSyncOptionsBuilder builder = customerSyncOptionsBuilder.getThis(); + assertThat(builder).isNotNull(); + assertThat(builder).isInstanceOf(CustomerSyncOptionsBuilder.class); + assertThat(builder).isEqualTo(customerSyncOptionsBuilder); + } + + @Test + void customerSyncOptionsBuilderSetters_ShouldBeCallableAfterBaseSyncOptionsBuildSetters() { + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(30) + .beforeCreateCallback((newCustomer) -> null) + .beforeUpdateCallback((updateActions, newCustomer, oldCustomer) -> emptyList()) + .build(); + assertThat(customerSyncOptions).isNotNull(); + } + + @Test + void batchSize_WithPositiveValue_ShouldSetBatchSize() { + CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(10) + .build(); + assertThat(customerSyncOptions.getBatchSize()).isEqualTo(10); + } + + @Test + void batchSize_WithZeroOrNegativeValue_ShouldFallBackToDefaultValue() { + final CustomerSyncOptions customerSyncOptionsWithZeroBatchSize = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(0) + .build(); + assertThat(customerSyncOptionsWithZeroBatchSize.getBatchSize()) + .isEqualTo(CustomerSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + + final CustomerSyncOptions customerSyncOptionsWithNegativeBatchSize = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(-100) + .build(); + assertThat(customerSyncOptionsWithNegativeBatchSize.getBatchSize()) + .isEqualTo(CustomerSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + } + + @Test + void applyBeforeUpdateCallBack_WithNullCallback_ShouldReturnIdenticalList() { + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder.of(CTP_CLIENT) + .build(); + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNull(); + + final List> updateActions = singletonList(ChangeEmail.of("mail@mail.com")); + + final List> filteredList = + customerSyncOptions.applyBeforeUpdateCallback(updateActions, mock(CustomerDraft.class), + mock(Customer.class)); + + assertThat(filteredList).isSameAs(updateActions); + } + + @Test + void applyBeforeUpdateCallBack_WithNullReturnCallback_ShouldReturnEmptyList() { + final TriFunction>, CustomerDraft, Customer, List>> + beforeUpdateCallback = (updateActions, newCustomer, oldCustomer) -> null; + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .beforeUpdateCallback(beforeUpdateCallback) + .build(); + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNotNull(); + + final List> updateActions = singletonList(ChangeEmail.of("mail@mail.com")); + final List> filteredList = + customerSyncOptions.applyBeforeUpdateCallback(updateActions, mock(CustomerDraft.class), + mock(Customer.class)); + assertThat(filteredList).isNotEqualTo(updateActions); + assertThat(filteredList).isEmpty(); + } + + private interface MockTriFunction extends + TriFunction>, CustomerDraft, Customer, List>> { + } + + @Test + void applyBeforeUpdateCallBack_WithEmptyUpdateActions_ShouldNotApplyBeforeUpdateCallback() { + final MockTriFunction beforeUpdateCallback = mock(MockTriFunction.class); + + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(CTP_CLIENT) + .beforeUpdateCallback(beforeUpdateCallback) + .build(); + + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNotNull(); + + final List> updateActions = emptyList(); + final List> filteredList = + customerSyncOptions.applyBeforeUpdateCallback(updateActions, mock(CustomerDraft.class), + mock(Customer.class)); + + assertThat(filteredList).isEmpty(); + verify(beforeUpdateCallback, never()).apply(any(), any(), any()); + } + + @Test + void applyBeforeUpdateCallBack_WithCallback_ShouldReturnFilteredList() { + final TriFunction>, CustomerDraft, Customer, List>> + beforeUpdateCallback = (updateActions, newType, oldType) -> emptyList(); + + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder + .of(CTP_CLIENT) + .beforeUpdateCallback(beforeUpdateCallback) + .build(); + assertThat(customerSyncOptions.getBeforeUpdateCallback()).isNotNull(); + + final List> updateActions = singletonList(ChangeEmail.of("mail@mail.com")); + final List> filteredList = + customerSyncOptions + .applyBeforeUpdateCallback(updateActions, mock(CustomerDraft.class), mock(Customer.class)); + assertThat(filteredList).isNotEqualTo(updateActions); + assertThat(filteredList).isEmpty(); + } + + @Test + void applyBeforeCreateCallBack_WithCallback_ShouldReturnFilteredDraft() { + final Function draftFunction = + customerDraft -> CustomerDraftBuilder.of(customerDraft) + .key(customerDraft.getKey() + "_filteredKey") + .build(); + + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback(draftFunction) + .build(); + + assertThat(customerSyncOptions.getBeforeCreateCallback()).isNotNull(); + + final CustomerDraft resourceDraft = mock(CustomerDraft.class); + when(resourceDraft.getKey()).thenReturn("myKey"); + when(resourceDraft.getDefaultBillingAddress()).thenReturn(null); + when(resourceDraft.getDefaultShippingAddress()).thenReturn(null); + + + final Optional filteredDraft = customerSyncOptions.applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).isNotEmpty(); + assertThat(filteredDraft.get().getKey()).isEqualTo("myKey_filteredKey"); + } + + @Test + void applyBeforeCreateCallBack_WithNullCallback_ShouldReturnIdenticalDraftInOptional() { + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder.of(CTP_CLIENT).build(); + assertThat(customerSyncOptions.getBeforeCreateCallback()).isNull(); + + final CustomerDraft resourceDraft = mock(CustomerDraft.class); + final Optional filteredDraft = customerSyncOptions.applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).containsSame(resourceDraft); + } + + @Test + void applyBeforeCreateCallBack_WithCallbackReturningNull_ShouldReturnEmptyOptional() { + final Function draftFunction = customerDraft -> null; + final CustomerSyncOptions customerSyncOptions = CustomerSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback(draftFunction) + .build(); + assertThat(customerSyncOptions.getBeforeCreateCallback()).isNotNull(); + + final CustomerDraft resourceDraft = mock(CustomerDraft.class); + final Optional filteredDraft = customerSyncOptions.applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).isEmpty(); + } + +} diff --git a/src/test/java/com/commercetools/sync/customers/CustomerSyncTest.java b/src/test/java/com/commercetools/sync/customers/CustomerSyncTest.java new file mode 100644 index 0000000000..231bc423ae --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/CustomerSyncTest.java @@ -0,0 +1,571 @@ +package com.commercetools.sync.customers; + +import com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics; +import com.commercetools.sync.commons.exceptions.ReferenceResolutionException; +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.customers.helpers.CustomerSyncStatistics; +import com.commercetools.sync.services.CustomerGroupService; +import com.commercetools.sync.services.CustomerService; +import com.commercetools.sync.services.TypeService; +import com.commercetools.sync.services.impl.TypeServiceImpl; +import io.sphere.sdk.client.BadRequestException; +import io.sphere.sdk.client.ConcurrentModificationException; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.models.SphereException; +import io.sphere.sdk.types.CustomFieldsDraft; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletionException; + +import static com.commercetools.sync.commons.helpers.CustomReferenceResolver.TYPE_DOES_NOT_EXIST; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_EMAIL_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_IS_NULL; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_KEY_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerReferenceResolver.FAILED_TO_RESOLVE_CUSTOM_TYPE; +import static io.sphere.sdk.utils.CompletableFutureUtils.exceptionallyCompletedFuture; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static java.util.Optional.empty; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; +import static org.assertj.core.api.InstanceOfAssertFactories.THROWABLE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CustomerSyncTest { + + private CustomerSyncOptions syncOptions; + private List errorMessages; + private List exceptions; + + @BeforeEach + void setup() { + errorMessages = new ArrayList<>(); + exceptions = new ArrayList<>(); + final SphereClient ctpClient = mock(SphereClient.class); + + syncOptions = CustomerSyncOptionsBuilder + .of(ctpClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorMessages.add(exception.getMessage()); + exceptions.add(exception); + }) + .build(); + } + + @Test + void sync_WithNullDraft_ShouldExecuteCallbackOnErrorAndIncreaseFailedCounter() { + final CustomerSync customerSync = new CustomerSync(syncOptions); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(null)) + .toCompletableFuture() + .join(); + + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + //assertions + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .isEqualTo(CUSTOMER_DRAFT_IS_NULL); + } + + @Test + void sync_WithoutKey_ShouldExecuteCallbackOnErrorAndIncreaseFailedCounter() { + final CustomerSync customerSync = new CustomerSync(syncOptions); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(CustomerDraftBuilder.of("email", "pass").build())) + .toCompletableFuture() + .join(); + + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + //assertions + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .isEqualTo(format(CUSTOMER_DRAFT_KEY_NOT_SET, "email")); + } + + @Test + void sync_WithoutEmail_ShouldExecuteCallbackOnErrorAndIncreaseFailedCounter() { + final CustomerSync customerSync = new CustomerSync(syncOptions); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(CustomerDraftBuilder.of(" ", "pass").key("key").build())) + .toCompletableFuture() + .join(); + + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + //assertions + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .isEqualTo(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, "key")); + } + + @Test + void sync_WithFailOnCachingKeysToIds_ShouldTriggerErrorCallbackAndReturnProperStats() { + // preparation + final TypeService typeService = spy(new TypeServiceImpl(syncOptions)); + when(typeService.cacheKeysToIds(anySet())) + .thenReturn(supplyAsync(() -> { + throw new SphereException(); + })); + + final CustomerSync customerSync = new CustomerSync(syncOptions, mock(CustomerService.class), + typeService, mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .customerGroup(ResourceIdentifier.ofKey("customerGroupKey")) + .custom(CustomFieldsDraft.ofTypeKeyAndJson("typeKey", emptyMap())) + .stores(asList(ResourceIdentifier.ofKey("storeKey1"), + ResourceIdentifier.ofKey("storeKey2"), + ResourceIdentifier.ofId("storeId3"))) + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)).isEqualTo("Failed to build a cache of keys to ids."); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasCauseExactlyInstanceOf(CompletionException.class) + .hasRootCauseExactlyInstanceOf(SphereException.class); + } + + @Test + void sync_WithErrorFetchingExistingKeys_ShouldExecuteCallbackOnErrorAndIncreaseFailedCounter() { + final CustomerService mockCustomerService = mock(CustomerService.class); + + when(mockCustomerService.fetchMatchingCustomersByKeys(singleton("customer-key"))) + .thenReturn(supplyAsync(() -> { + throw new SphereException(); + })); + + final CustomerSync customerSync = new CustomerSync(syncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + // test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(CustomerDraftBuilder.of("email", "pass") + .key("customer-key") + .build())) + .toCompletableFuture() + .join(); + + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .isEqualTo("Failed to fetch existing customers with keys: '[customer-key]'."); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasCauseExactlyInstanceOf(CompletionException.class) + .hasRootCauseExactlyInstanceOf(SphereException.class); + } + + @Test + void sync_WithNonExistingTypeReference_ShouldTriggerErrorCallbackAndReturnProperStats() { + // preparation + final TypeService mockTypeService = mock(TypeService.class); + when(mockTypeService.fetchCachedTypeId(anyString())).thenReturn(completedFuture(empty())); + when(mockTypeService.cacheKeysToIds(anySet())).thenReturn(completedFuture(emptyMap())); + + final CustomerService mockCustomerService = mock(CustomerService.class); + when(mockCustomerService.fetchMatchingCustomersByKeys(singleton("customerKey"))) + .thenReturn(completedFuture(new HashSet<>(singletonList(mock(Customer.class))))); + + final CustomerSync customerSync = new CustomerSync(syncOptions, mockCustomerService, + mockTypeService, mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .custom(CustomFieldsDraft.ofTypeKeyAndJson("typeKey", emptyMap())) + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + final String expectedExceptionMessage = + format(FAILED_TO_RESOLVE_CUSTOM_TYPE, customerDraft.getKey()); + final String expectedMessageWithCause = + format("%s Reason: %s", expectedExceptionMessage, format(TYPE_DOES_NOT_EXIST, "typeKey")); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .contains(expectedMessageWithCause); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasCauseExactlyInstanceOf(CompletionException.class) + .hasRootCauseExactlyInstanceOf(ReferenceResolutionException.class); + } + + @Test + void sync_WithOnlyDraftsToCreate_ShouldCallBeforeCreateCallbackAndIncrementCreated() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomerService.fetchMatchingCustomersByKeys(singleton("customerKey"))) + .thenReturn(completedFuture(new HashSet<>(singletonList(mockCustomer)))); + + when(mockCustomerService.createCustomer(any())) + .thenReturn(completedFuture(Optional.of(mockCustomer))); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 1, 0, 0); + + verify(spyCustomerSyncOptions).applyBeforeCreateCallback(customerDraft); + verify(spyCustomerSyncOptions, never()).applyBeforeUpdateCallback(any(), any(), any()); + } + + @Test + void sync_FailedOnCreation_ShouldCallBeforeCreateCallbackAndIncrementFailed() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomerService.fetchMatchingCustomersByKeys(singleton("customerKey"))) + .thenReturn(completedFuture(new HashSet<>(singletonList(mockCustomer)))); + + // simulate an error during create, service will return an empty optional. + when(mockCustomerService.createCustomer(any())) + .thenReturn(completedFuture(Optional.empty())); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + verify(spyCustomerSyncOptions).applyBeforeCreateCallback(customerDraft); + verify(spyCustomerSyncOptions, never()).applyBeforeUpdateCallback(any(), any(), any()); + } + + @Test + void sync_WithOnlyDraftsToUpdate_ShouldOnlyCallBeforeUpdateCallback() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + when(mockCustomerService.updateCustomer(any(), anyList())) + .thenReturn(completedFuture(mockCustomer)); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 1, 0); + + verify(spyCustomerSyncOptions).applyBeforeUpdateCallback(any(), any(), any()); + verify(spyCustomerSyncOptions, never()).applyBeforeCreateCallback(customerDraft); + } + + @Test + void sync_WithoutUpdateActions_ShouldNotIncrementUpdated() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + when(mockCustomer.getEmail()).thenReturn("email"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 0); + + verify(spyCustomerSyncOptions).applyBeforeUpdateCallback(emptyList(), customerDraft, mockCustomer); + verify(spyCustomerSyncOptions, never()).applyBeforeCreateCallback(customerDraft); + } + + @Test + void sync_WithBadRequestException_ShouldFailToUpdateAndIncreaseFailedCounter() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + when(mockCustomerService.updateCustomer(any(), anyList())) + .thenReturn(exceptionallyCompletedFuture(new BadRequestException("Invalid request"))); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .contains("Invalid request"); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasRootCauseExactlyInstanceOf(BadRequestException.class); + } + + @Test + void sync_WithConcurrentModificationException_ShouldRetryToUpdateNewCustomerWithSuccess() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + when(mockCustomerService.updateCustomer(any(), anyList())) + .thenReturn(exceptionallyCompletedFuture(new SphereException(new ConcurrentModificationException()))) + .thenReturn(completedFuture(mockCustomer)); + + when(mockCustomerService.fetchCustomerByKey("customerKey")) + .thenReturn(completedFuture(Optional.of(mockCustomer))); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 1, 0); + } + + @Test + void sync_WithConcurrentModificationExceptionAndFailedFetch_ShouldFailToReFetchAndUpdate() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + when(mockCustomerService.updateCustomer(any(), anyList())) + .thenReturn(exceptionallyCompletedFuture(new SphereException(new ConcurrentModificationException()))) + .thenReturn(completedFuture(mockCustomer)); + + when(mockCustomerService.fetchCustomerByKey("customerKey")) + .thenReturn(exceptionallyCompletedFuture(new SphereException())); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .contains("Failed to fetch from CTP while retrying after concurrency modification."); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasRootCauseExactlyInstanceOf(SphereException.class); + } + + @Test + void sync_WithConcurrentModificationExceptionAndUnexpectedDelete_ShouldFailToReFetchAndUpdate() { + // preparation + final CustomerService mockCustomerService = mock(CustomerService.class); + final Customer mockCustomer = mock(Customer.class); + when(mockCustomer.getKey()).thenReturn("customerKey"); + + when(mockCustomerService.fetchMatchingCustomersByKeys(anySet())) + .thenReturn(completedFuture(singleton(mockCustomer))); + + when(mockCustomerService.updateCustomer(any(), anyList())) + .thenReturn(exceptionallyCompletedFuture(new SphereException(new ConcurrentModificationException()))) + .thenReturn(completedFuture(mockCustomer)); + + when(mockCustomerService.fetchCustomerByKey("customerKey")) + .thenReturn(completedFuture(Optional.empty())); + + final CustomerSyncOptions spyCustomerSyncOptions = spy(syncOptions); + final CustomerSync customerSync = new CustomerSync(spyCustomerSyncOptions, mockCustomerService, + mock(TypeService.class), mock(CustomerGroupService.class)); + + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .build(); + + //test + final CustomerSyncStatistics customerSyncStatistics = customerSync + .sync(singletonList(customerDraft)) + .toCompletableFuture() + .join(); + + // assertions + AssertionsForStatistics.assertThat(customerSyncStatistics).hasValues(1, 0, 0, 1); + + assertThat(errorMessages) + .hasSize(1) + .singleElement(as(STRING)) + .contains("Not found when attempting to fetch while retrying after concurrency modification."); + + assertThat(exceptions) + .hasSize(1) + .singleElement(as(THROWABLE)) + .isExactlyInstanceOf(SyncException.class) + .hasNoCause(); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKeyTest.java b/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKeyTest.java new file mode 100644 index 0000000000..b80263a592 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKeyTest.java @@ -0,0 +1,18 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AddBillingAddressIdWithKeyTest { + + @Test + void whenAddBillingAddressIdWithKeyIsCreated_thenGetAddressKeyReturnsCorrectKey() { + + final String billingAddressKey = "key"; + final AddBillingAddressIdWithKey addBillingAddressIdWithKey = AddBillingAddressIdWithKey.of(billingAddressKey); + + assertThat(addBillingAddressIdWithKey.getAddressKey()).isEqualTo("key"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKeyTest.java b/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKeyTest.java new file mode 100644 index 0000000000..897d7760c1 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKeyTest.java @@ -0,0 +1,18 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AddShippingAddressIdWithKeyTest { + + @Test + void whenAddShippingAddressIdWithKeyIsCreated_thenGetAddressKeyReturnsCorrectKey() { + + final String shippingAddressKey = "key"; + final AddShippingAddressIdWithKey addShippingAddressIdWithKey = AddShippingAddressIdWithKey + .of(shippingAddressKey); + + assertThat(addShippingAddressIdWithKey.getAddressKey()).isEqualTo("key"); + } +} \ No newline at end of file diff --git a/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKeyTest.java b/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKeyTest.java new file mode 100644 index 0000000000..4b61996217 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKeyTest.java @@ -0,0 +1,18 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SetDefaultBillingAddressWithKeyTest { + + @Test + void whenSetDefaultBillingAddressWithKeyIsCreated_thenGetAddressKeyReturnsCorrectKey() { + + final String billingAddressKey = "key"; + final SetDefaultBillingAddressWithKey setDefaultBillingAddressWithKey = SetDefaultBillingAddressWithKey + .of(billingAddressKey); + + assertThat(setDefaultBillingAddressWithKey.getAddressKey()).isEqualTo("key"); + } +} \ No newline at end of file diff --git a/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKeyTest.java b/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKeyTest.java new file mode 100644 index 0000000000..8e49b1bc05 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKeyTest.java @@ -0,0 +1,19 @@ +package com.commercetools.sync.customers.commands.updateactions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SetDefaultShippingAddressWithKeyTest { + + @Test + void whenSetDefaultShippingAddressWithKeyIsCreated_thenGetAddressKeyReturnsCorrectKey() { + + final String shippingAddressKey = "key"; + final SetDefaultShippingAddressWithKey setDefaultShippingAddressWithKey = SetDefaultShippingAddressWithKey + .of(shippingAddressKey); + + assertThat(setDefaultShippingAddressWithKey.getAddressKey()).isEqualTo("key"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/commercetools/sync/customers/helpers/CustomerBatchValidatorTest.java b/src/test/java/com/commercetools/sync/customers/helpers/CustomerBatchValidatorTest.java new file mode 100644 index 0000000000..5986b603ae --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/helpers/CustomerBatchValidatorTest.java @@ -0,0 +1,403 @@ +package com.commercetools.sync.customers.helpers; + +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.types.CustomFieldsDraft; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.ADDRESSES_ARE_NULL; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.ADDRESSES_THAT_KEYS_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.ADDRESSES_THAT_KEYS_NOT_UNIQUE; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.BILLING_ADDRESSES_ARE_NOT_VALID; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_EMAIL_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_IS_NULL; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_KEY_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.CUSTOMER_DRAFT_PASSWORD_NOT_SET; +import static com.commercetools.sync.customers.helpers.CustomerBatchValidator.SHIPPING_ADDRESSES_ARE_NOT_VALID; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CustomerBatchValidatorTest { + + private CustomerSyncOptions syncOptions; + private CustomerSyncStatistics syncStatistics; + private List errorCallBackMessages; + + @BeforeEach + void setup() { + errorCallBackMessages = new ArrayList<>(); + final SphereClient ctpClient = mock(SphereClient.class); + + syncOptions = CustomerSyncOptionsBuilder + .of(ctpClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> + errorCallBackMessages.add(exception.getMessage())) + .build(); + syncStatistics = mock(CustomerSyncStatistics.class); + } + + @Test + void validateAndCollectReferencedKeys_WithEmptyDraft_ShouldHaveEmptyResult() { + final Set validDrafts = getValidDrafts(emptyList()); + + assertThat(errorCallBackMessages).hasSize(0); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithNullCustomerDraft_ShouldHaveValidationErrorAndEmptyResult() { + final Set validDrafts = getValidDrafts(singletonList(null)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)).isEqualTo(CUSTOMER_DRAFT_IS_NULL); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithNullKey_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", "pass").build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_KEY_NOT_SET, customerDraft.getEmail())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithEmptyKey_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", "pass") + .key(EMPTY) + .build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_KEY_NOT_SET, customerDraft.getEmail())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithNullEmail_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of(null, "pass") + .key("key") + .build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, customerDraft.getKey())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithEmptyEmail_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of(EMPTY, "pass") + .key("key") + .build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, customerDraft.getKey())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithNullPassword_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", null) + .key("key") + .build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_PASSWORD_NOT_SET, customerDraft.getKey())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithCustomerDraftWithEmptyPassword_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", EMPTY) + .key("key") + .build(); + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_PASSWORD_NOT_SET, customerDraft.getKey())); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithNullAddressList_ShouldNotHaveValidationError() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(null) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(0); + assertThat(validDrafts).containsExactly(customerDraft); + } + + @Test + void validateAndCollectReferencedKeys_WithEmptyAddressList_ShouldNotHaveValidationError() { + final CustomerDraft customerDraft = CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(emptyList()) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(0); + assertThat(validDrafts).containsExactly(customerDraft); + } + + @Test + void validateAndCollectReferencedKeys_WithNullAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList(null, Address.of(CountryCode.DE), null)) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(ADDRESSES_ARE_NULL, "key", "[0, 2]")); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithBlankKeysInAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.DE).withKey(null), + Address.of(CountryCode.US).withKey("address-key2"), + Address.of(CountryCode.AC).withKey(" "))) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(ADDRESSES_THAT_KEYS_NOT_SET, "key", "[1, 3]")); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithDuplicateKeysInAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.DE).withKey("address-key3"), + Address.of(CountryCode.US).withKey("address-key1"), + Address.of(CountryCode.US).withKey("address-key3"))) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(ADDRESSES_THAT_KEYS_NOT_UNIQUE, "key", "[0, 2, 3, 4]")); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithInvalidBillingAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.US).withKey("address-key3"))) + .billingAddresses(asList(null, -1, 1, 2)) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(BILLING_ADDRESSES_ARE_NOT_VALID, "key", "[null, -1]")); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithInvalidShippingAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.US).withKey("address-key3"))) + .shippingAddresses(asList(1, 2, 3, 4, null)) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(1); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(SHIPPING_ADDRESSES_ARE_NOT_VALID, "key", "[3, 4, null]")); + assertThat(validDrafts).isEmpty(); + } + + @Test + void validateAndCollectReferencedKeys_WithAllValidAddresses_ShouldNotHaveValidationError() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.US).withKey("address-key3"))) + .defaultShippingAddress(0) + .shippingAddresses(asList(0,1)) + .defaultBillingAddress(1) + .billingAddresses(asList(1,2)) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(0); + assertThat(validDrafts).containsExactly(customerDraft); + } + + @Test + void validateAndCollectReferencedKeys_WithEmptyBillingAndShippingAddresses_ShouldNotHaveValidationError() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.US).withKey("address-key3"))) + .defaultShippingAddress(0) + .shippingAddresses(emptyList()) + .defaultBillingAddress(1) + .billingAddresses(emptyList()) + .build(); + + final Set validDrafts = getValidDrafts(singletonList(customerDraft)); + + assertThat(errorCallBackMessages).hasSize(0); + assertThat(validDrafts).containsExactly(customerDraft); + } + + @Test + void validateAndCollectReferencedKeys_WithIndexesWithoutAddresses_ShouldHaveValidationErrorAndEmptyResult() { + final CustomerDraft customerDraft1 = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(emptyList()) + .shippingAddresses(asList(0, 1)) + .build(); + + final CustomerDraft customerDraft2 = + CustomerDraftBuilder.of("email", "pass") + .key("key") + .addresses(null) + .billingAddresses(asList(0, 1)) + .build(); + + final Set validDrafts = getValidDrafts(asList(customerDraft1, customerDraft2)); + + assertThat(errorCallBackMessages).hasSize(2); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(SHIPPING_ADDRESSES_ARE_NOT_VALID, "key", "[0, 1]")); + assertThat(errorCallBackMessages.get(1)) + .isEqualTo(format(BILLING_ADDRESSES_ARE_NOT_VALID, "key", "[0, 1]")); + assertThat(validDrafts).isEmpty(); + + } + + @Test + void validateAndCollectReferencedKeys_WithMixedDrafts_ShouldReturnCorrectResults() { + final CustomerDraft customerDraft = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey") + .customerGroup(ResourceIdentifier.ofKey("customerGroupKey")) + .custom(CustomFieldsDraft.ofTypeKeyAndJson("typeKey", emptyMap())) + .stores(asList(ResourceIdentifier.ofKey("storeKey1"), + ResourceIdentifier.ofKey("storeKey2"), + ResourceIdentifier.ofId("storeId3"))) + .build(); + + final CustomerDraft customerDraft2 = + CustomerDraftBuilder.of("email", "pass") + .key("customerKey2") + .customerGroup(ResourceIdentifier.ofId("customerGroupId2")) + .custom(CustomFieldsDraft.ofTypeIdAndJson("typeId2", emptyMap())) + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key1"), + Address.of(CountryCode.FR).withKey("address-key2"), + Address.of(CountryCode.US).withKey("address-key3"))) + .build(); + + final CustomerDraft customerDraft3 = + CustomerDraftBuilder.of("email", "pass") + .key(" ") + .customerGroup(ResourceIdentifier.ofKey("customerGroupKey3")) + .build(); + + final CustomerDraft customerDraft4 = + CustomerDraftBuilder.of("", "pass") + .key("customerKey4") + .custom(CustomFieldsDraft.ofTypeKeyAndJson("typeId4", emptyMap())) + .build(); + + final CustomerBatchValidator customerBatchValidator = new CustomerBatchValidator(syncOptions, syncStatistics); + final ImmutablePair, CustomerBatchValidator.ReferencedKeys> pair + = customerBatchValidator.validateAndCollectReferencedKeys( + Arrays.asList(customerDraft, customerDraft2, customerDraft3, customerDraft4)); + + assertThat(errorCallBackMessages).hasSize(2); + assertThat(errorCallBackMessages.get(0)) + .isEqualTo(format(CUSTOMER_DRAFT_KEY_NOT_SET, customerDraft3.getEmail())); + assertThat(errorCallBackMessages.get(1)) + .isEqualTo(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, customerDraft4.getKey())); + + assertThat(pair.getLeft()).containsExactlyInAnyOrder(customerDraft, customerDraft2); + assertThat(pair.getRight().getTypeKeys()).containsExactlyInAnyOrder("typeKey"); + assertThat(pair.getRight().getCustomerGroupKeys()).containsExactlyInAnyOrder("customerGroupKey"); + } + + @Nonnull + private Set getValidDrafts(@Nonnull final List customerDrafts) { + final CustomerBatchValidator customerBatchValidator = + new CustomerBatchValidator(syncOptions, syncStatistics); + final ImmutablePair, CustomerBatchValidator.ReferencedKeys> pair = + customerBatchValidator.validateAndCollectReferencedKeys(customerDrafts); + return pair.getLeft(); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolverTest.java b/src/test/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolverTest.java new file mode 100644 index 0000000000..b3f0b9639c --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolverTest.java @@ -0,0 +1,333 @@ +package com.commercetools.sync.customers.helpers; + +import com.commercetools.sync.commons.exceptions.ReferenceResolutionException; +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.commercetools.sync.services.CustomerGroupService; +import com.commercetools.sync.services.TypeService; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.models.SphereException; +import io.sphere.sdk.types.CustomFieldsDraft; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static com.commercetools.sync.commons.MockUtils.getMockTypeService; +import static com.commercetools.sync.commons.helpers.BaseReferenceResolver.BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER; +import static com.commercetools.sync.commons.helpers.CustomReferenceResolver.TYPE_DOES_NOT_EXIST; +import static com.commercetools.sync.customers.helpers.CustomerReferenceResolver.CUSTOMER_GROUP_DOES_NOT_EXIST; +import static com.commercetools.sync.customers.helpers.CustomerReferenceResolver.FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE; +import static com.commercetools.sync.customers.helpers.CustomerReferenceResolver.FAILED_TO_RESOLVE_CUSTOM_TYPE; +import static com.commercetools.sync.customers.helpers.CustomerReferenceResolver.FAILED_TO_RESOLVE_STORE_REFERENCE; +import static com.commercetools.sync.products.ProductSyncMockUtils.getMockCustomerGroup; +import static com.commercetools.sync.products.ProductSyncMockUtils.getMockCustomerGroupService; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomerReferenceResolverTest { + + private CustomerReferenceResolver referenceResolver; + private TypeService typeService; + private CustomerGroupService customerGroupService; + + private static final String CUSTOMER_GROUP_KEY = "customer-group-key"; + private static final String CUSTOMER_GROUP_ID = UUID.randomUUID().toString(); + + @BeforeEach + void setup() { + final CustomerSyncOptions syncOptions = CustomerSyncOptionsBuilder + .of(mock(SphereClient.class)) + .build(); + + typeService = getMockTypeService(); + + customerGroupService = getMockCustomerGroupService( + getMockCustomerGroup(CUSTOMER_GROUP_ID, CUSTOMER_GROUP_KEY)); + + referenceResolver = new CustomerReferenceResolver(syncOptions, typeService, customerGroupService); + } + + @Test + void resolveReferences_WithoutReferences_ShouldNotResolveReferences() { + final CustomerDraft customerDraft = CustomerDraftBuilder + .of("email@example.com", "secret123") + .build(); + + final CustomerDraft resolvedDraft = referenceResolver + .resolveReferences(customerDraft) + .toCompletableFuture() + .join(); + + assertThat(resolvedDraft).isEqualTo(customerDraft); + } + + @Test + void resolveCustomTypeReference_WithNullKeyOnCustomTypeReference_ShouldNotResolveCustomTypeReference() { + final CustomFieldsDraft newCustomFieldsDraft = mock(CustomFieldsDraft.class); + when(newCustomFieldsDraft.getType()).thenReturn(ResourceIdentifier.ofId(null)); + + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .key("customer-key") + .custom(newCustomFieldsDraft); + + assertThat(referenceResolver.resolveCustomTypeReference(customerDraftBuilder).toCompletableFuture()) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format("Failed to resolve custom type reference on CustomerDraft with " + + "key:'customer-key'. Reason: %s", BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } + + @Test + void resolveCustomTypeReference_WithEmptyKeyOnCustomTypeReference_ShouldNotResolveCustomTypeReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .key("customer-key") + .custom(CustomFieldsDraft.ofTypeKeyAndJson("", emptyMap())); + + assertThat(referenceResolver.resolveCustomTypeReference(customerDraftBuilder).toCompletableFuture()) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format("Failed to resolve custom type reference on CustomerDraft with " + + "key:'customer-key'. Reason: %s", BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } + + @Test + void resolveCustomTypeReference_WithNonExistentCustomType_ShouldCompleteExceptionally() { + final String customTypeKey = "nonExistingKey"; + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .key("customer-key") + .custom(CustomFieldsDraft.ofTypeKeyAndJson(customTypeKey, emptyMap())); + + when(typeService.fetchCachedTypeId(anyString())) + .thenReturn(completedFuture(Optional.empty())); + + final String expectedExceptionMessage = + format(FAILED_TO_RESOLVE_CUSTOM_TYPE, customerDraftBuilder.getKey()); + final String expectedMessageWithCause = + format("%s Reason: %s", expectedExceptionMessage, format(TYPE_DOES_NOT_EXIST, customTypeKey)); + assertThat(referenceResolver.resolveCustomTypeReference(customerDraftBuilder)) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(expectedMessageWithCause); + } + + @Test + void resolveReferences_WithAllValidReferences_ShouldResolveReferences() { + final CustomerDraft customerDraft = CustomerDraftBuilder + .of("email@example.com", "secret123") + .key("customer-key") + .custom(CustomFieldsDraft.ofTypeKeyAndJson("typeKey", emptyMap())) + .build(); + + final CustomerDraft resolvedDraft = referenceResolver + .resolveReferences(customerDraft) + .toCompletableFuture() + .join(); + + final CustomerDraft expectedDraft = CustomerDraftBuilder + .of(customerDraft) + .custom(CustomFieldsDraft.ofTypeIdAndJson("typeId", new HashMap<>())) + .build(); + + assertThat(resolvedDraft).isEqualTo(expectedDraft); + } + + @Test + void resolveCustomerGroupReference_WithKeys_ShouldResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofKey("customer-group-key")); + + final CustomerDraftBuilder resolvedDraft = referenceResolver.resolveCustomerGroupReference(customerDraftBuilder) + .toCompletableFuture().join(); + + assertThat(resolvedDraft.getCustomerGroup()).isNotNull(); + assertThat(resolvedDraft.getCustomerGroup().getId()).isEqualTo(CUSTOMER_GROUP_ID); + } + + @Test + void resolveCustomerGroupReference_WithNonExistentCustomerGroup_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofKey("anyKey")) + .key("dummyKey"); + + when(customerGroupService.fetchCachedCustomerGroupId(anyString())) + .thenReturn(completedFuture(Optional.empty())); + + final String expectedMessageWithCause = format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, + "dummyKey", format(CUSTOMER_GROUP_DOES_NOT_EXIST, "anyKey")); + + assertThat(referenceResolver.resolveCustomerGroupReference(customerDraftBuilder)) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(expectedMessageWithCause); + } + + @Test + void resolveCustomerGroupReference_WithNullKeyOnCustomerGroupReference_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofKey(null)) + .key("dummyKey"); + + assertThat(referenceResolver.resolveCustomerGroupReference(customerDraftBuilder).toCompletableFuture()) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, "dummyKey", + BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } + + @Test + void resolveCustomerGroupReference_WithEmptyKeyOnCustomerGroupReference_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofKey("")) + .key("dummyKey"); + + assertThat(referenceResolver.resolveCustomerGroupReference(customerDraftBuilder).toCompletableFuture()) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, "dummyKey", + BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } + + @Test + void resolveCustomerGroupReference_WithExceptionOnCustomerGroupFetch_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofKey("anyKey")) + .key("dummyKey"); + + final CompletableFuture> futureThrowingSphereException = new CompletableFuture<>(); + futureThrowingSphereException.completeExceptionally(new SphereException("CTP error on fetch")); + when(customerGroupService.fetchCachedCustomerGroupId(anyString())).thenReturn(futureThrowingSphereException); + + assertThat(referenceResolver.resolveCustomerGroupReference(customerDraftBuilder)) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(SphereException.class) + .hasMessageContaining("CTP error on fetch"); + } + + @Test + void resolveCustomerGroupReference_WithIdOnCustomerGroupReference_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .customerGroup(ResourceIdentifier.ofId("existingId")) + .key("dummyKey"); + + assertThat(referenceResolver.resolveCustomerGroupReference(customerDraftBuilder).toCompletableFuture()) + .hasNotFailed() + .isCompletedWithValueMatching(resolvedDraft -> Objects.equals(resolvedDraft.getCustomerGroup(), + customerDraftBuilder.getCustomerGroup())); + } + + @Test + void resolveStoreReferences_WithNullStoreReferences_ShouldNotResolveReferences() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(null) + .key("dummyKey"); + + final CustomerDraftBuilder resolvedDraft = referenceResolver + .resolveStoreReferences(customerDraftBuilder) + .toCompletableFuture() + .join(); + + assertThat(resolvedDraft.getStores()).isNull(); + } + + @Test + void resolveStoreReferences_WithEmptyStoreReferences_ShouldNotResolveReferences() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(emptyList()) + .key("dummyKey"); + + final CustomerDraftBuilder resolvedDraft = referenceResolver + .resolveStoreReferences(customerDraftBuilder) + .toCompletableFuture() + .join(); + + assertThat(resolvedDraft.getStores()).isEmpty(); + } + + @Test + void resolveStoreReferences_WithValidStores_ShouldResolveReferences() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(asList(ResourceIdentifier.ofKey("store-key1"), + ResourceIdentifier.ofKey("store-key2"), + ResourceIdentifier.ofId("store-id-3"))) + .key("dummyKey"); + + final CustomerDraftBuilder resolvedDraft = referenceResolver + .resolveStoreReferences(customerDraftBuilder) + .toCompletableFuture() + .join(); + + assertThat(resolvedDraft.getStores()) + .containsExactly(ResourceIdentifier.ofKey("store-key1"), + ResourceIdentifier.ofKey("store-key2"), + ResourceIdentifier.ofId("store-id-3")); + } + + @Test + void resolveStoreReferences_WithNullStore_ShouldResolveReferences() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(singletonList(null)) + .key("dummyKey"); + + final CustomerDraftBuilder resolvedDraft = referenceResolver + .resolveStoreReferences(customerDraftBuilder) + .toCompletableFuture() + .join(); + + assertThat(resolvedDraft.getStores()).isEmpty(); + } + + @Test + void resolveStoreReferences_WithNullKeyOnStoreReference_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(singletonList(ResourceIdentifier.ofKey(null))) + .key("dummyKey"); + + assertThat(referenceResolver.resolveStoreReferences(customerDraftBuilder)) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format(FAILED_TO_RESOLVE_STORE_REFERENCE, "dummyKey", BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } + + @Test + void resolveStoreReferences_WithBlankKeyOnStoreReference_ShouldNotResolveReference() { + final CustomerDraftBuilder customerDraftBuilder = CustomerDraftBuilder + .of("email@example.com", "secret123") + .stores(singletonList(ResourceIdentifier.ofKey(" "))) + .key("dummyKey"); + + assertThat(referenceResolver.resolveStoreReferences(customerDraftBuilder)) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(ReferenceResolutionException.class) + .hasMessage(format(FAILED_TO_RESOLVE_STORE_REFERENCE, "dummyKey", BLANK_KEY_VALUE_ON_RESOURCE_IDENTIFIER)); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/helpers/CustomerSyncStatisticsTest.java b/src/test/java/com/commercetools/sync/customers/helpers/CustomerSyncStatisticsTest.java new file mode 100644 index 0000000000..2fcddafa2a --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/helpers/CustomerSyncStatisticsTest.java @@ -0,0 +1,28 @@ +package com.commercetools.sync.customers.helpers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +class CustomerSyncStatisticsTest { + private CustomerSyncStatistics customerSyncStatistics; + + @BeforeEach + void setup() { + customerSyncStatistics = new CustomerSyncStatistics(); + } + + @Test + void getReportMessage_WithIncrementedStats_ShouldGetCorrectMessage() { + customerSyncStatistics.incrementCreated(1); + customerSyncStatistics.incrementFailed(2); + customerSyncStatistics.incrementUpdated(3); + customerSyncStatistics.incrementProcessed(6); + + assertThat(customerSyncStatistics.getReportMessage()) + .isEqualTo("Summary: 6 customers were processed in total " + + "(1 created, 3 updated and 2 failed to sync)."); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/utils/AddressUpdateActionUtilsTest.java b/src/test/java/com/commercetools/sync/customers/utils/AddressUpdateActionUtilsTest.java new file mode 100644 index 0000000000..a8971fe576 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/utils/AddressUpdateActionUtilsTest.java @@ -0,0 +1,1617 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.customers.commands.updateactions.AddBillingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.AddShippingAddressIdWithKey; +import com.commercetools.sync.customers.commands.updateactions.SetDefaultBillingAddressWithKey; +import com.commercetools.sync.customers.commands.updateactions.SetDefaultShippingAddressWithKey; +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.updateactions.AddAddress; +import io.sphere.sdk.customers.commands.updateactions.ChangeAddress; +import io.sphere.sdk.customers.commands.updateactions.RemoveAddress; +import io.sphere.sdk.customers.commands.updateactions.RemoveBillingAddressId; +import io.sphere.sdk.customers.commands.updateactions.RemoveShippingAddressId; +import io.sphere.sdk.models.Address; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAddAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAddBillingAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAddShippingAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAllAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildChangeAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildRemoveAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildRemoveBillingAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildRemoveShippingAddressUpdateActions; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetDefaultBillingAddressUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetDefaultShippingAddressUpdateAction; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AddressUpdateActionUtilsTest { + + private Customer oldCustomer; + + @BeforeEach + void setup() { + oldCustomer = mock(Customer.class); + } + + @Test + void buildAllAddressUpdateActions_WithDifferentAddresses_ShouldReturnAddressAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + when(oldCustomer.getDefaultShippingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2")); + when(oldCustomer.getDefaultBillingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final Address address3 = Address.of(CountryCode.DE) + .withKey("address-key-3") + .withId("address-id-new-3"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address2, address3)) + .defaultShippingAddress(1) + .defaultBillingAddress(0) + .build(); + + final List> updateActions = + buildAllAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly( + RemoveAddress.of("address-id-1"), + ChangeAddress.of("address-id-2", address2), + AddAddress.of(address3), + SetDefaultShippingAddressWithKey.of("address-key-3"), + SetDefaultBillingAddressWithKey.of("address-key-2")); + } + + @Test + void buildAllAddressUpdateActions_WithSameAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"), + Address.of(CountryCode.DE).withKey("address-key-2") + .withBuilding("no 1") + .withId("address-id-new-2") + )) + .build(); + + final List> updateActions = + buildAllAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAllAddressUpdateActions_withRemovedAddresses_ShouldFilterOutRemoveBillingAndShippingAddressIdActions() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withBuilding("no 1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .build(); + // test + final List> updateActions = + buildAllAddressUpdateActions(oldCustomer, newCustomer); + // assertions + assertThat(updateActions).containsExactly(RemoveAddress.of("address-id-1")); + } + + @Test + void buildRemoveAddressUpdateActions_WithoutOldAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(emptyList()); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList(Address.of(CountryCode.DE).withKey("address-key-1"))) + .build(); + + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveAddressUpdateActions_WithEmptyAddressKey_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withBuilding("no 1"))); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList(Address.of(CountryCode.DE).withKey(""))) + .build(); + // test + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).containsExactly(RemoveAddress.of("address-id-1")); + } + + @Test + void buildRemoveAddressUpdateActions_WithNullNewAddresses_ShouldReturnRemoveAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(null) + .build(); + + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly( + RemoveAddress.of("address-id-1"), + RemoveAddress.of("address-id-2")); + } + + @Test + void buildRemoveAddressUpdateActions_WithEmptyNewAddresses_ShouldReturnRemoveAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(emptyList()) + .build(); + + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly( + RemoveAddress.of("address-id-1"), + RemoveAddress.of("address-id-2")); + } + + @Test + void buildRemoveAddressUpdateActions_WithNullAddresses_ShouldReturnRemoveAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(null, null)) + .build(); + + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly( + RemoveAddress.of("address-id-1"), RemoveAddress.of("address-id-2")); + } + + @Test + void buildRemoveAddressUpdateActions_WithAddressesWithoutKeys_ShouldReturnRemoveAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))) + .build(); + + final List> updateActions = + buildRemoveAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveAddress.of("address-id-2")); + } + + @Test + void buildChangeAddressUpdateActions_WithoutNewAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(null) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildChangeAddressUpdateActions_WithEmptyNewAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(emptyList()) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildChangeAddressUpdateActions_WithSameAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"), + Address.of(CountryCode.DE).withKey("address-key-2") + .withBuilding("no 1") + .withId("address-id-new-2") + )) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildChangeAddressUpdateActions_WithDifferentAddressesData_ShouldReturnChangeAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("321"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 2") + )); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final Address address3 = Address.of(CountryCode.DE) + .withKey("address-key-3") + .withBuilding("no 1") + .withId("address-id-new-3"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2, address3)) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(ChangeAddress.of("address-id-1", address1)); + } + + @Test + void buildChangeAddressUpdateActions_WithNullAddresses_ShouldNotReturnChangeAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList(null)) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildChangeAddressUpdateActions_WithAddressesWithoutKeys_ShouldNotReturnChangeAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withId("address-id-1"))) + .build(); + + final List> updateActions = + buildChangeAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithoutNewAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(null) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithEmptyNewAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(emptyList()) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithSameAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"), + Address.of(CountryCode.DE).withKey("address-key-2") + .withBuilding("no 1") + .withId("address-id-new-2") + )) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithNewAddresses_ShouldReturnAddAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final Address address3 = Address.of(CountryCode.DE) + .withKey("address-key-3") + .withBuilding("no 1") + .withId("address-id-new-3"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2, address3)) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(AddAddress.of(address3)); + } + + @Test + void buildAddAddressUpdateActions_WithNullAddresses_ShouldNotReturnAddAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(null, null)) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithAddressesWithoutKeys_ShouldNotReturnAddAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withId("address-id-2") + )); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))) + .build(); + + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddAddressUpdateActions_WithEmptyAddressKey_ShouldNotReturnAction() { + // preparation + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withBuilding("no 1") + .withId("address-id-1"); + when(oldCustomer.getAddresses()).thenReturn(Collections.singletonList(address1)); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(singletonList(Address.of(CountryCode.DE).withKey(""))) + .build(); + // test + final List> updateActions = + buildAddAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).isEmpty(); + } + + @Test + void buildSetDefaultShippingAddressUpdateAction_WithSameDefaultShippingAddress_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultShippingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultShippingAddress(0) + .build(); + + final Optional> customerUpdateAction = + buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction).isNotPresent(); + } + + @Test + void buildSetDefaultShippingAddressUpdateAction_WithDifferentDefaultShippingAddress_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultShippingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultShippingAddress(1) + .build(); + + final Optional> customerUpdateAction = + buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultShippingAddressWithKey.of("address-key-2")); + } + + @Test + void buildSetDefaultShippingAddressUpdateAction_WithNoExistingShippingAddress_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(emptyList()); + when(oldCustomer.getDefaultShippingAddress()).thenReturn(null); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultShippingAddress(1) + .build(); + // test + final Optional> customerUpdateAction = + buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer); + + // assertions + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultShippingAddressWithKey.of("address-key-2")); + } + + @Test + void buildSetDefaultShippingAddressUpdateAction_WithoutDefaultShippingAddress_ShouldReturnUnsetAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultShippingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultShippingAddress(null) + .build(); + + + final Optional> customerUpdateAction = + buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultShippingAddressWithKey.of(null)); + } + + @Test + void buildSetDefaultBillingAddressUpdateAction_WithSameDefaultBillingAddress_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultBillingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultBillingAddress(0) + .build(); + + final Optional> customerUpdateAction = + buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction).isNotPresent(); + } + + @Test + void buildSetDefaultBillingAddressUpdateAction_WithDifferentDefaultBillingAddress_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultBillingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultBillingAddress(1) + .build(); + + final Optional> customerUpdateAction = + buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultBillingAddressWithKey.of("address-key-2")); + } + + @Test + void buildSetDefaultBillingAddressUpdateAction_WithoutDefaultBillingAddress_ShouldReturnUnsetAction() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getDefaultBillingAddress()) + .thenReturn(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultBillingAddress(null) + .build(); + + + final Optional> customerUpdateAction = + buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer); + + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultBillingAddressWithKey.of(null)); + } + + @Test + void buildSetDefaultBillingAddressUpdateAction_WithNoExistingBillingAddress_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(emptyList()); + when(oldCustomer.getDefaultBillingAddress()).thenReturn(null); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )) + .defaultBillingAddress(1) + .build(); + // test + final Optional> customerUpdateAction = + buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer); + + // assertions + assertThat(customerUpdateAction) + .isPresent() + .contains(SetDefaultBillingAddressWithKey.of("address-key-2")); + } + + @Test + void buildAddShippingAddressUpdateActions_WithoutNewShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .shippingAddresses(null) + .build(); + + final List> updateActions = + buildAddShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddShippingAddressUpdateActions_WithEmptyShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .shippingAddresses(emptyList()) + .build(); + + final List> updateActions = + buildAddShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddShippingAddressUpdateActions_WithSameShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildAddShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddShippingAddressUpdateActions_WithEmptyAddressKey_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("").withId("address-id-2").withBuilding("no 1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("").withId("address-id-1"))); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2)) + .shippingAddresses(singletonList(1)) + .build(); + // test + final List> updateActions = + buildAddShippingAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).containsExactly(AddShippingAddressIdWithKey.of("address-key-2")); + } + + @Test + void buildAddShippingAddressUpdateActions_WithNewShippingAddresses_ShouldReturnAddShippingAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2)) + .shippingAddresses(asList(0, 1)) + .build(); + + final List> updateActions = + buildAddShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(AddShippingAddressIdWithKey.of("address-key-2")); + } + + @Test + void buildAddShippingAddressUpdateActions_WithShippingAddressIdLessThanZero_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(-1)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", -1)); + } + + @Test + void buildAddShippingAddressUpdateActions_InOutBoundOfTheExistingIndexes_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(2)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", 2)); + } + + @Test + void buildAddShippingAddressUpdateActions_WithAddressListSizeNull_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(null) + .shippingAddresses(singletonList(0)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", 0)); + } + + @Test + void buildAddShippingAddressUpdateActions_WithIndexBiggerThanListSize_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(3)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", 3)); + } + + @Test + void buildAddShippingAddressUpdateActions_InWithNullAddress_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList(null)) + .shippingAddresses(singletonList(0)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Address is null at the index: %s of the addresses list.", 0)); + } + + @Test + void buildAddShippingAddressUpdateActions_InWithBlankKey_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withId("address-id-1") + )) + .shippingAddresses(singletonList(0)) + .build(); + + assertThatThrownBy(() -> buildAddShippingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Address does not have a key at the index: %s of the addresses list.", 0)); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithEmptyOldShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()).thenReturn(emptyList()); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithBlankOldKey_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()).thenReturn( + singletonList(Address.of(CountryCode.DE).withKey(""))); + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .build(); + // test + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).containsExactly(RemoveShippingAddressId.of(null)); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WitNullOldShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()).thenReturn(null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithEmptyNewShippingAddresses_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(emptyList()) + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveShippingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithNullNewShippingAddresses_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(null) + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveShippingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithSameShippingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithOldShippingAddresses_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + final Address address3 = Address.of(CountryCode.DE) + .withKey("address-key-3") + .withId("address-id-3"); + + final Address address4 = Address.of(CountryCode.DE) + .withKey("address-key-4") + .withId("address-id-new-4"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address3, address4)) + .shippingAddresses(singletonList(1)) + .build(); + // test + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + // assertions + assertThat(updateActions).containsExactly(RemoveShippingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveShippingAddressUpdateActions_WithLessShippingAddresses_ShouldReturnRemoveShippingAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getShippingAddresses()) + .thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .shippingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveShippingAddressId.of("address-id-2")); + } + + @Test + void buildAddBillingAddressUpdateActions_WithoutNewBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .billingAddresses(null) + .build(); + + final List> updateActions = + buildAddBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddBillingAddressUpdateActions_WithEmptyBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .billingAddresses(emptyList()) + .build(); + + final List> updateActions = + buildAddBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddBillingAddressUpdateActions_WithSameBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildAddBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildAddBillingAddressUpdateActions_WithNewBillingAddresses_ShouldReturnAddBillingAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2").withBuilding("no 1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2)) + .billingAddresses(asList(0, 1)) + .build(); + + final List> updateActions = + buildAddBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(AddBillingAddressIdWithKey.of("address-key-2")); + } + + @Test + void buildAddBillingAddressUpdateActions_WithEmptyAddressKey_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("").withId("address-id-1").withPostalCode("123"), + Address.of(CountryCode.DE).withKey("").withId("address-id-2").withBuilding("no 1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("").withId("address-id-1"))); + + final Address address1 = Address.of(CountryCode.DE) + .withKey("address-key-1") + .withPostalCode("123") + .withId("address-id-new-1"); + + final Address address2 = Address.of(CountryCode.DE) + .withKey("address-key-2") + .withBuilding("no 2") + .withId("address-id-new-2"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address1, address2)) + .billingAddresses(singletonList(1)) + .build(); + // test + final List> updateActions = + buildAddBillingAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).containsExactly(AddBillingAddressIdWithKey.of("address-key-2")); + } + + @Test + void buildAddBillingAddressUpdateActions_WithShippingAddressIdLessThanZero_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(singletonList(-1)) + .build(); + + assertThatThrownBy(() -> buildAddBillingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", -1)); + } + + @Test + void buildAddBillingAddressUpdateActions_InOutBoundOfTheExistingIndexes_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(singletonList(2)) + .build(); + + assertThatThrownBy(() -> buildAddBillingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Addresses list does not contain an address at the index: %s", 2)); + } + + @Test + void buildAddBillingAddressUpdateActions_InWithNullAddress_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList(null)) + .billingAddresses(singletonList(0)) + .build(); + + assertThatThrownBy(() -> buildAddBillingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Address is null at the index: %s of the addresses list.", 0)); + } + + @Test + void buildAddBillingAddressUpdateActions_InWithBlankKey_ShouldThrowIllegalArgumentException() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withId("address-id-1") + )) + .billingAddresses(singletonList(0)) + .build(); + + assertThatThrownBy(() -> buildAddBillingAddressUpdateActions(oldCustomer, newCustomer)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage(format("Address does not have a key at the index: %s of the addresses list.", 0)); + } + + + @Test + void buildRemoveBillingAddressUpdateActions_WithOldBillingAddresses_ShouldReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + + + final Address address3 = Address.of(CountryCode.DE) + .withKey("address-key-3") + .withId("address-id-3"); + + final Address address4 = Address.of(CountryCode.DE) + .withKey("address-key-4") + .withId("address-id-new-4"); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .addresses(asList(address3, address4)) + .billingAddresses(singletonList(1)) + .build(); + // test + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + // assertions + assertThat(updateActions).containsExactly(RemoveBillingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithEmptyOldBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()).thenReturn(emptyList()); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WitNullOldBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()).thenReturn(null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithEmptyNewBillingAddresses_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(emptyList()) + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveBillingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithNullNewBillingAddresses_ShouldReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(null) + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveBillingAddressId.of("address-id-1")); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithSameBillingAddresses_ShouldNotReturnAction() { + + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(singletonList(Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithEmptyOldKey_ShouldNotReturnAction() { + // preparation + when(oldCustomer.getAddresses()).thenReturn(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )); + when(oldCustomer.getBillingAddresses()).thenReturn( + singletonList(Address.of(CountryCode.DE).withKey(""))); + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .build(); + // test + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + // assertions + assertThat(updateActions).containsExactly(RemoveBillingAddressId.of(null)); + } + + @Test + void buildRemoveBillingAddressUpdateActions_WithLessBillingAddresses_ShouldReturnRemoveBillingAddressActions() { + + when(oldCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2") + )); + when(oldCustomer.getBillingAddresses()) + .thenReturn(asList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1"), + Address.of(CountryCode.DE).withKey("address-key-2").withId("address-id-2"))); + + final CustomerDraft newCustomer = + CustomerDraftBuilder + .of("email", "pass") + .addresses(singletonList( + Address.of(CountryCode.DE).withKey("address-key-1").withId("address-id-1") + )) + .billingAddresses(singletonList(0)) + .build(); + + final List> updateActions = + buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).containsExactly(RemoveBillingAddressId.of("address-id-2")); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtilsTest.java b/src/test/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtilsTest.java new file mode 100644 index 0000000000..9c04f66c5a --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtilsTest.java @@ -0,0 +1,190 @@ +package com.commercetools.sync.customers.utils; + +import com.neovisionaries.i18n.CountryCode; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.queries.CustomerQuery; +import io.sphere.sdk.expansion.ExpansionPath; +import io.sphere.sdk.models.Address; +import io.sphere.sdk.models.KeyReference; +import io.sphere.sdk.models.Reference; +import io.sphere.sdk.stores.Store; +import io.sphere.sdk.types.CustomFields; +import io.sphere.sdk.types.Type; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static com.commercetools.sync.commons.MockUtils.getTypeMock; +import static com.commercetools.sync.products.ProductSyncMockUtils.getMockCustomerGroup; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomerReferenceResolutionUtilsTest { + + @Test + void mapToCustomerDrafts_WithExpandedReferences_ShouldReturnResourceIdentifiersWithKeys() { + final Type mockCustomType = getTypeMock(UUID.randomUUID().toString(), "customTypeKey"); + final CustomerGroup mockCustomerGroup = getMockCustomerGroup(UUID.randomUUID().toString(), "customerGroupKey"); + final String storeKey1 = "storeKey1"; + final String storeKey2 = "storeKey2"; + + final List mockCustomers = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + final Customer mockCustomer = mock(Customer.class); + + final CustomFields mockCustomFields = mock(CustomFields.class); + final Reference typeReference = Reference.ofResourceTypeIdAndObj(Type.referenceTypeId(), + mockCustomType); + when(mockCustomFields.getType()).thenReturn(typeReference); + when(mockCustomer.getCustom()).thenReturn(mockCustomFields); + + final Reference customerGroupReference = + Reference.ofResourceTypeIdAndObj(CustomerGroup.referenceTypeId(), mockCustomerGroup); + when(mockCustomer.getCustomerGroup()).thenReturn(customerGroupReference); + + List> keyReferences = asList( + KeyReference.of(storeKey1, Store.referenceTypeId()), + KeyReference.of(storeKey2, Store.referenceTypeId())); + + when(mockCustomer.getStores()).thenReturn(keyReferences); + when(mockCustomer.getAddresses()).thenReturn(null); + + mockCustomers.add(mockCustomer); + } + + final List referenceReplacedDrafts = + CustomerReferenceResolutionUtils.mapToCustomerDrafts(mockCustomers); + + referenceReplacedDrafts.forEach(draft -> { + assertThat(draft.getCustom().getType().getKey()).isEqualTo(mockCustomType.getKey()); + assertThat(draft.getCustomerGroup().getKey()).isEqualTo(mockCustomerGroup.getKey()); + assertThat(draft.getStores().get(0).getKey()).isEqualTo(storeKey1); + assertThat(draft.getStores().get(1).getKey()).isEqualTo(storeKey2); + }); + } + + @Test + void mapToCustomerDrafts_WithNonExpandedReferences_ShouldReturnResourceIdentifiersWithoutKeys() { + final String customTypeId = UUID.randomUUID().toString(); + final String customerGroupId = UUID.randomUUID().toString(); + + final List mockCustomers = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + final Customer mockCustomer = mock(Customer.class); + + final CustomFields mockCustomFields = mock(CustomFields.class); + final Reference typeReference = Reference.ofResourceTypeIdAndId("resourceTypeId", + customTypeId); + when(mockCustomFields.getType()).thenReturn(typeReference); + when(mockCustomer.getCustom()).thenReturn(mockCustomFields); + + final Reference customerGroupReference = + Reference.ofResourceTypeIdAndId(CustomerGroup.referenceTypeId(), customerGroupId); + when(mockCustomer.getCustomerGroup()).thenReturn(customerGroupReference); + + when(mockCustomer.getStores()).thenReturn(null); + when(mockCustomer.getAddresses()).thenReturn(null); + + mockCustomers.add(mockCustomer); + } + + final List referenceReplacedDrafts = + CustomerReferenceResolutionUtils.mapToCustomerDrafts(mockCustomers); + + referenceReplacedDrafts.forEach(draft -> { + assertThat(draft.getCustom().getType().getId()).isEqualTo(customTypeId); + assertThat(draft.getCustom().getType().getKey()).isNull(); + assertThat(draft.getCustomerGroup().getId()).isEqualTo(customerGroupId); + assertThat(draft.getCustomerGroup().getKey()).isNull(); + }); + } + + @Test + void mapToCustomerDrafts_WithAddresses_ShouldReturnResourceIdentifiersWithCorrectIndexes() { + final Customer mockCustomer = mock(Customer.class); + + when(mockCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withId("address-id1"), + Address.of(CountryCode.FR).withId("address-id2"), + Address.of(CountryCode.US).withId("address-id3"))); + when(mockCustomer.getDefaultBillingAddressId()).thenReturn("address-id1"); + when(mockCustomer.getDefaultShippingAddressId()).thenReturn("address-id2"); + when(mockCustomer.getBillingAddressIds()).thenReturn(asList("address-id1", "address-id3")); + when(mockCustomer.getShippingAddressIds()).thenReturn(asList("address-id2", "address-id3")); + + final List referenceReplacedDrafts = + CustomerReferenceResolutionUtils.mapToCustomerDrafts(singletonList(mockCustomer)); + + final CustomerDraft customerDraft = referenceReplacedDrafts.get(0); + + assertThat(customerDraft.getAddresses()).isEqualTo(mockCustomer.getAddresses()); + assertThat(customerDraft.getDefaultBillingAddress()).isEqualTo(0); + assertThat(customerDraft.getDefaultShippingAddress()).isEqualTo(1); + assertThat(customerDraft.getBillingAddresses()).isEqualTo(asList(0, 2)); + assertThat(customerDraft.getShippingAddresses()).isEqualTo(asList(1, 2)); + } + + @Test + void mapToCustomerDrafts_WithMissingAddresses_ShouldReturnResourceIdentifiersWithCorrectIndexes() { + final Customer mockCustomer = mock(Customer.class); + + when(mockCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withId("address-id1"), + Address.of(CountryCode.FR).withId("address-id2"), + Address.of(CountryCode.US).withId("address-id3"))); + when(mockCustomer.getDefaultBillingAddressId()).thenReturn("non-existing-id"); + when(mockCustomer.getDefaultShippingAddressId()).thenReturn(null); + when(mockCustomer.getBillingAddressIds()).thenReturn(asList("address-id1", "non-existing-id")); + when(mockCustomer.getShippingAddressIds()).thenReturn(asList(" ", "address-id3", null)); + + final List referenceReplacedDrafts = + CustomerReferenceResolutionUtils.mapToCustomerDrafts(singletonList(mockCustomer)); + + final CustomerDraft customerDraft = referenceReplacedDrafts.get(0); + + assertThat(customerDraft.getAddresses()).isEqualTo(mockCustomer.getAddresses()); + assertThat(customerDraft.getDefaultBillingAddress()).isNull(); + assertThat(customerDraft.getDefaultShippingAddress()).isNull(); + assertThat(customerDraft.getBillingAddresses()).isEqualTo(asList(0, null)); + assertThat(customerDraft.getShippingAddresses()).isEqualTo(asList(null, 2, null)); + } + + @Test + void mapToCustomerDrafts_WithNullIdOnAddresses_ShouldReturnResourceIdentifiersWithCorrectIndexes() { + final Customer mockCustomer = mock(Customer.class); + + when(mockCustomer.getAddresses()).thenReturn(asList( + Address.of(CountryCode.DE).withId("address-id1"), + Address.of(CountryCode.US).withId(null), + Address.of(CountryCode.US).withId("address-id3"))); + when(mockCustomer.getDefaultBillingAddressId()).thenReturn("address-id1"); + when(mockCustomer.getDefaultShippingAddressId()).thenReturn("address-id2"); + when(mockCustomer.getBillingAddressIds()).thenReturn(asList("address-id1", "address-id3")); + when(mockCustomer.getShippingAddressIds()).thenReturn(null); + + final List referenceReplacedDrafts = + CustomerReferenceResolutionUtils.mapToCustomerDrafts(singletonList(mockCustomer)); + + final CustomerDraft customerDraft = referenceReplacedDrafts.get(0); + + assertThat(customerDraft.getAddresses()).isEqualTo(mockCustomer.getAddresses()); + assertThat(customerDraft.getDefaultBillingAddress()).isEqualTo(0); + assertThat(customerDraft.getDefaultShippingAddress()).isNull(); + assertThat(customerDraft.getBillingAddresses()).isEqualTo(asList(0, 2)); + assertThat(customerDraft.getShippingAddresses()).isNull(); + } + + @Test + void buildCustomerQuery_Always_ShouldReturnQueryWithAllNeededReferencesExpanded() { + final CustomerQuery customerQuery = CustomerReferenceResolutionUtils.buildCustomerQuery(); + assertThat(customerQuery.expansionPaths()) + .containsExactly(ExpansionPath.of("customerGroup"), ExpansionPath.of("custom.type")); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/utils/CustomerSyncUtilsTest.java b/src/test/java/com/commercetools/sync/customers/utils/CustomerSyncUtilsTest.java new file mode 100644 index 0000000000..73183d6d51 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/utils/CustomerSyncUtilsTest.java @@ -0,0 +1,125 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.updateactions.SetCustomField; +import io.sphere.sdk.customers.commands.updateactions.SetCustomType; +import io.sphere.sdk.types.CustomFields; +import io.sphere.sdk.types.CustomFieldsDraft; +import io.sphere.sdk.types.CustomFieldsDraftBuilder; +import io.sphere.sdk.types.Type; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.commercetools.sync.customers.utils.CustomerSyncUtils.buildActions; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomerSyncUtilsTest { + + private static final String CUSTOM_TYPE_ID = "id"; + private static final String CUSTOM_FIELD_NAME = "field"; + private static final String CUSTOM_FIELD_VALUE = "value"; + + private Customer oldCustomer; + + @BeforeEach + void setup() { + oldCustomer = mock(Customer.class); + when(oldCustomer.getEmail()).thenReturn("email"); + + final CustomFields customFields = mock(CustomFields.class); + when(customFields.getType()).thenReturn(Type.referenceOfId(CUSTOM_TYPE_ID)); + + final Map customFieldsJsonMapMock = new HashMap<>(); + customFieldsJsonMapMock.put(CUSTOM_FIELD_NAME, JsonNodeFactory.instance.textNode(CUSTOM_FIELD_VALUE)); + when(customFields.getFieldsJsonMap()).thenReturn(customFieldsJsonMapMock); + + when(oldCustomer.getCustom()).thenReturn(customFields); + } + + @Test + void buildActions_WithDifferentCustomType_ShouldBuildUpdateAction() { + final CustomFieldsDraft customFieldsDraft = + CustomFieldsDraftBuilder.ofTypeId("newId") + .addObject("newField", "newValue") + .build(); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .custom(customFieldsDraft) + .build(); + + final List> actions = + buildActions(oldCustomer, newCustomer, CustomerSyncOptionsBuilder.of(mock(SphereClient.class)).build()); + + assertThat(actions).containsExactly( + SetCustomType.ofTypeIdAndJson(customFieldsDraft.getType().getId(), customFieldsDraft.getFields())); + } + + @Test + void buildActions_WithSameCustomTypeWithNewCustomFields_ShouldBuildUpdateAction() { + final CustomFieldsDraft sameCustomFieldDraftWithNewCustomField = + CustomFieldsDraftBuilder.ofTypeId(CUSTOM_TYPE_ID) + .addObject(CUSTOM_FIELD_NAME, CUSTOM_FIELD_VALUE) + .addObject("name_2", "value_2") + .build(); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .custom(sameCustomFieldDraftWithNewCustomField) + .build(); + + final List> actions = + buildActions(oldCustomer, newCustomer, CustomerSyncOptionsBuilder.of(mock(SphereClient.class)).build()); + + + assertThat(actions).containsExactly( + SetCustomField.ofJson("name_2", JsonNodeFactory.instance.textNode("value_2"))); + } + + @Test + void buildActions_WithSameCustomTypeWithDifferentCustomFieldValues_ShouldBuildUpdateAction() { + + final CustomFieldsDraft sameCustomFieldDraftWithNewValue = + CustomFieldsDraftBuilder.ofTypeId(CUSTOM_TYPE_ID) + .addObject(CUSTOM_FIELD_NAME, + "newValue") + .build(); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .custom(sameCustomFieldDraftWithNewValue) + .build(); + + final List> actions = + buildActions(oldCustomer, newCustomer, CustomerSyncOptionsBuilder.of(mock(SphereClient.class)).build()); + + assertThat(actions).containsExactly( + SetCustomField.ofJson(CUSTOM_FIELD_NAME, JsonNodeFactory.instance.textNode("newValue"))); + } + + @Test + void buildActions_WithJustNewCartDiscountHasNullCustomType_ShouldBuildUpdateAction() { + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .custom(null) + .build(); + + final List> actions = + buildActions(oldCustomer, newCustomer, CustomerSyncOptionsBuilder.of(mock(SphereClient.class)).build()); + + assertThat(actions).containsExactly(SetCustomType.ofRemoveType()); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtilsTest.java b/src/test/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtilsTest.java new file mode 100644 index 0000000000..20eace2487 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtilsTest.java @@ -0,0 +1,428 @@ +package com.commercetools.sync.customers.utils; + +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customergroups.CustomerGroup; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.CustomerName; +import io.sphere.sdk.customers.commands.updateactions.ChangeEmail; +import io.sphere.sdk.customers.commands.updateactions.SetCompanyName; +import io.sphere.sdk.customers.commands.updateactions.SetCustomerGroup; +import io.sphere.sdk.customers.commands.updateactions.SetCustomerNumber; +import io.sphere.sdk.customers.commands.updateactions.SetDateOfBirth; +import io.sphere.sdk.customers.commands.updateactions.SetExternalId; +import io.sphere.sdk.customers.commands.updateactions.SetFirstName; +import io.sphere.sdk.customers.commands.updateactions.SetLastName; +import io.sphere.sdk.customers.commands.updateactions.SetLocale; +import io.sphere.sdk.customers.commands.updateactions.SetMiddleName; +import io.sphere.sdk.customers.commands.updateactions.SetSalutation; +import io.sphere.sdk.customers.commands.updateactions.SetTitle; +import io.sphere.sdk.customers.commands.updateactions.SetVatId; +import io.sphere.sdk.models.Reference; +import io.sphere.sdk.models.ResourceIdentifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.CUSTOMER_NUMBER_EXISTS_WARNING; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildChangeEmailUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCompanyNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerGroupUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerNumberUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetDateOfBirthUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetExternalIdUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetFirstNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLastNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLocaleUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetMiddleNameUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetSalutationUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetTitleUpdateAction; +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetVatIdUpdateAction; +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CustomerUpdateActionUtilsTest { + + private static Customer old; + private static CustomerDraft newSame; + private static CustomerDraft newDifferent; + + @BeforeEach + void setup() { + CustomerName oldCustomerName = CustomerName.of("old-title", "old-firstName", + "old-middleName", "old-lastName"); + + final String key = "key1"; + final String companyName = "companyName1"; + final String salutation = "salutation1"; + final String vatId = "vatId1"; + final String locale = "DE"; + final String birthDate = "1990-10-01"; + final String externalId = "externalId1"; + final String customerNumber = "1234"; + + old = mock(Customer.class); + when(old.getKey()).thenReturn(key); + when(old.getName()).thenReturn(oldCustomerName); + when(old.getEmail()).thenReturn("old-email"); + when(old.getFirstName()).thenReturn("old-firstName"); + when(old.getMiddleName()).thenReturn("old-middleName"); + when(old.getLastName()).thenReturn("old-lastName"); + when(old.getTitle()).thenReturn("old-title"); + when(old.getSalutation()).thenReturn(salutation); + when(old.getCustomerNumber()).thenReturn(customerNumber); + when(old.getExternalId()).thenReturn(externalId); + when(old.getCompanyName()).thenReturn(companyName); + when(old.getDateOfBirth()).thenReturn(LocalDate.parse(birthDate)); + when(old.getVatId()).thenReturn(vatId); + when(old.getLocale()).thenReturn(Locale.forLanguageTag(locale)); + + + newSame = CustomerDraftBuilder.of(oldCustomerName, "old-email", "oldPW") + .key(key) + .companyName(companyName) + .salutation(salutation) + .dateOfBirth(LocalDate.parse(birthDate)) + .locale(Locale.forLanguageTag(locale)) + .vatId(vatId) + .externalId(externalId) + .customerNumber(customerNumber) + .build(); + + CustomerName newCustomerName = CustomerName.of("new-title", "new-firstName", + "new-middleName", "new-lastName"); + + newDifferent = CustomerDraftBuilder.of(newCustomerName, "new-email", "newPW").build(); + } + + @Test + void buildChangeEmailUpdateAction_WithDifferentValues_ShouldReturnAction() { + final Optional> result = buildChangeEmailUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(ChangeEmail.class); + assertThat(result).contains(ChangeEmail.of(newDifferent.getEmail())); + } + + @Test + void buildChangeEmailUpdateAction_WithSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildChangeEmailUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetFirstNameUpdateAction_WithDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetFirstNameUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetFirstName.class); + assertThat(result).contains(SetFirstName.of(newDifferent.getFirstName())); + } + + @Test + void buildSetFirstNameUpdateAction_WithSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetFirstNameUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetLastNameUpdateAction_WithDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetLastNameUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetLastName.class); + assertThat(result).contains(SetLastName.of(newDifferent.getLastName())); + } + + @Test + void buildSetLastNameUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetLastNameUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetMiddleNameUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetMiddleNameUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetMiddleName.class); + assertThat(result).contains(SetMiddleName.of(newDifferent.getMiddleName())); + } + + @Test + void buildSetMiddleNameUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetMiddleNameUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetTitleUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetTitleUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetTitle.class); + assertThat(result).contains(SetTitle.of(newDifferent.getTitle())); + } + + @Test + void buildSetTitleUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetTitleUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetSalutationUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetSalutationUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetSalutation.class); + assertThat(result).contains(SetSalutation.of(newDifferent.getSalutation())); + } + + @Test + void buildSetSalutationUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetSalutationUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetCustomerNumberUpdateAction_withDifferentValues_ShouldNotReturnActionAndAddWarningCallback() { + final List warningMessages = new ArrayList<>(); + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(mock(SphereClient.class)) + .warningCallback((exception, oldResource, newResource) + -> warningMessages.add(exception.getMessage())) + .build(); + + final Optional> result = + buildSetCustomerNumberUpdateAction(old, newDifferent, customerSyncOptions); + + assertThat(result).isEmpty(); + assertThat(warningMessages).containsExactly(format(CUSTOMER_NUMBER_EXISTS_WARNING, old.getKey(), "1234")); + } + + @Test + void buildSetCustomerNumberUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final List warningMessages = new ArrayList<>(); + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(mock(SphereClient.class)) + .warningCallback((exception, oldResource, newResource) + -> warningMessages.add(exception.getMessage())) + .build(); + + final Optional> result = + buildSetCustomerNumberUpdateAction(old, newSame, customerSyncOptions); + + assertThat(result).isEmpty(); + assertThat(warningMessages).isEmpty(); + } + + @Test + void buildSetCustomerNumberUpdateAction_withEmptyOldValueAndANewValue_ShouldReturnAction() { + final List warningMessages = new ArrayList<>(); + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(mock(SphereClient.class)) + .warningCallback((exception, oldResource, newResource) + -> warningMessages.add(exception.getMessage())) + .build(); + + Customer oldCustomer = mock(Customer.class); + when(oldCustomer.getCustomerNumber()).thenReturn(" "); + + CustomerDraft newCustomer = mock(CustomerDraft.class); + when(newCustomer.getCustomerNumber()).thenReturn("customer-number"); + + final Optional> result = + buildSetCustomerNumberUpdateAction(oldCustomer, newCustomer, customerSyncOptions); + + assertThat(result).containsInstanceOf(SetCustomerNumber.class); + assertThat(result).contains(SetCustomerNumber.of("customer-number")); + assertThat(warningMessages).isEmpty(); + } + + @Test + void buildSetCustomerNumberUpdateAction_withNullOldValueAndANewValue_ShouldReturnAction() { + final List warningMessages = new ArrayList<>(); + final CustomerSyncOptions customerSyncOptions = + CustomerSyncOptionsBuilder.of(mock(SphereClient.class)) + .warningCallback((exception, oldResource, newResource) + -> warningMessages.add(exception.getMessage())) + .build(); + + Customer oldCustomer = mock(Customer.class); + when(oldCustomer.getCustomerNumber()).thenReturn(null); + + CustomerDraft newCustomer = mock(CustomerDraft.class); + when(newCustomer.getCustomerNumber()).thenReturn("customer-number"); + + final Optional> result = + buildSetCustomerNumberUpdateAction(oldCustomer, newCustomer, customerSyncOptions); + + assertThat(result).containsInstanceOf(SetCustomerNumber.class); + assertThat(result).contains(SetCustomerNumber.of("customer-number")); + assertThat(warningMessages).isEmpty(); + } + + @Test + void buildSetExternalIdUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetExternalIdUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetExternalId.class); + assertThat(result).contains(SetExternalId.of(newDifferent.getExternalId())); + } + + @Test + void buildSetExternalIdUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetExternalIdUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetCompanyNameUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetCompanyNameUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetCompanyName.class); + assertThat(result).contains(SetCompanyName.of(newDifferent.getCompanyName())); + } + + @Test + void buildSetCompanyNameUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetCompanyNameUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetDateOfBirthUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetDateOfBirthUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetDateOfBirth.class); + assertThat(result).contains(SetDateOfBirth.of(newDifferent.getDateOfBirth())); + } + + @Test + void buildSetDateOfBirthUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetDateOfBirthUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetVatIdUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetVatIdUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetVatId.class); + assertThat(result).contains(SetVatId.of(newDifferent.getVatId())); + } + + @Test + void buildSetVatIdUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetVatIdUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetLocaleUpdateAction_withDifferentValues_ShouldReturnAction() { + final Optional> result = buildSetLocaleUpdateAction(old, newDifferent); + + assertThat(result).containsInstanceOf(SetLocale.class); + assertThat(result).contains(SetLocale.of(newDifferent.getLocale())); + } + + @Test + void buildSetLocaleUpdateAction_withSameValues_ShouldReturnEmptyOptional() { + final Optional> result = buildSetLocaleUpdateAction(old, newSame); + + assertThat(result).isEmpty(); + } + + @Test + void buildSetCustomerGroupAction_WithSameReference_ShouldNotReturnAction() { + final String customerGroupId = UUID.randomUUID().toString(); + final Reference customerGroupReference = + Reference.of(CustomerGroup.referenceTypeId(), customerGroupId); + + final Customer oldCustomer = mock(Customer.class); + when(oldCustomer.getCustomerGroup()).thenReturn(customerGroupReference); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .customerGroup(ResourceIdentifier.ofId(customerGroupId)) + .build(); + + final Optional> result = buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer); + assertThat(result).isNotPresent(); + } + + @Test + void buildSetCustomerGroupAction_WithDifferentReference_ShouldReturnAction() { + final String customerGroupId = UUID.randomUUID().toString(); + final Reference customerGroupReference = + Reference.of(CustomerGroup.referenceTypeId(), customerGroupId); + + final Customer oldCustomer = mock(Customer.class); + when(oldCustomer.getCustomerGroup()).thenReturn(customerGroupReference); + + final String resolvedCustomerGroupId = UUID.randomUUID().toString(); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .customerGroup(ResourceIdentifier.ofId(resolvedCustomerGroupId)) + .build(); + + final Optional> result = buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer); + assertThat(result).isPresent(); + assertThat(result).containsInstanceOf(SetCustomerGroup.class); + assertThat(((SetCustomerGroup) result.get()).getCustomerGroup()) + .isEqualTo(Reference.of(CustomerGroup.referenceTypeId(), resolvedCustomerGroupId)); + } + + @Test + void buildSetCustomerGroupAction_WithOnlyNewReference_ShouldReturnAction() { + final Customer oldCustomer = mock(Customer.class); + final String newCustomerGroupId = UUID.randomUUID().toString(); + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .customerGroup(ResourceIdentifier.ofId(newCustomerGroupId)) + .build(); + + final Optional> result = buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer); + assertThat(result).isPresent(); + assertThat(result).containsInstanceOf(SetCustomerGroup.class); + assertThat(((SetCustomerGroup) result.get()).getCustomerGroup()) + .isEqualTo(Reference.of(CustomerGroup.referenceTypeId(), newCustomerGroupId)); + } + + @Test + void buildSetCustomerGroupAction_WithoutNewReference_ShouldReturnUnsetAction() { + final String customerGroupId = UUID.randomUUID().toString(); + final Reference customerGroupReference = + Reference.of(CustomerGroup.referenceTypeId(), customerGroupId); + + final Customer oldCustomer = mock(Customer.class); + when(oldCustomer.getCustomerGroup()).thenReturn(customerGroupReference); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .build(); + + final Optional> result = buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer); + assertThat(result).isPresent(); + assertThat(result).containsInstanceOf(SetCustomerGroup.class); + //Note: If the old value is set, but the new one is empty - the command will unset the customer group. + assertThat(((SetCustomerGroup) result.get()).getCustomerGroup()).isNull(); + } +} diff --git a/src/test/java/com/commercetools/sync/customers/utils/StoreUpdateActionUtilsTest.java b/src/test/java/com/commercetools/sync/customers/utils/StoreUpdateActionUtilsTest.java new file mode 100644 index 0000000000..aa68091df4 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customers/utils/StoreUpdateActionUtilsTest.java @@ -0,0 +1,333 @@ +package com.commercetools.sync.customers.utils; + +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerDraftBuilder; +import io.sphere.sdk.customers.commands.updateactions.AddStore; +import io.sphere.sdk.customers.commands.updateactions.RemoveStore; +import io.sphere.sdk.customers.commands.updateactions.SetStores; +import io.sphere.sdk.models.KeyReference; +import io.sphere.sdk.models.ResourceIdentifier; +import io.sphere.sdk.stores.Store; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildStoreUpdateActions; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class StoreUpdateActionUtilsTest { + + private Customer oldCustomer; + + @BeforeEach + void setup() { + oldCustomer = mock(Customer.class); + } + + @Test + void buildStoreUpdateActions_WithSameStores_ShouldNotReturnAction() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key"); + when(oldCustomer.getStores()).thenReturn(singletonList(keyReference1)); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(singletonList(ResourceIdentifier.ofKey("store-key"))) + .build(); + + final List> updateActions = buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildStoreUpdateActions_WithNullOldStores_ShouldReturnOnlySetStoreAction() { + + when(oldCustomer.getStores()).thenReturn(null); + + final List> newStores = singletonList(ResourceIdentifier.ofKey("store-key")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of(newStores)); + } + + @Test + void buildStoreUpdateActions_WithEmptyOldStores_ShouldReturnOnlySetStoreAction() { + + when(oldCustomer.getStores()).thenReturn(emptyList()); + + final List> newStores = singletonList(ResourceIdentifier.ofKey("store-key")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of(newStores)); + } + + @Test + void buildStoreUpdateActions_WithEmptyOldStores_ShouldReturnOnlySetStoreWithoutNullReferencesInIt() { + + when(oldCustomer.getStores()).thenReturn(emptyList()); + + final List> newStores = + asList(ResourceIdentifier.ofKey("store-key"), null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of(singletonList(ResourceIdentifier.ofKey("store-key")))); + } + + @Test + void buildStoreUpdateActions_WithOnlyNullNewStores_ShouldNotReturnAction() { + + when(oldCustomer.getStores()).thenReturn(emptyList()); + + final List> newStores = + asList(null, null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildStoreUpdateActions_WithBothNullStoreReferences_ShouldNotReturnAction() { + + when(oldCustomer.getStores()).thenReturn(asList(null, null)); + + final List> newStores = + asList(null, null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildStoreUpdateActions_WithNullNewStores_ShouldReturnSetStoreActionWithUnset() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key"); + when(oldCustomer.getStores()).thenReturn(singletonList(keyReference1)); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(null) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of(emptyList())); + } + + @Test + void buildStoreUpdateActions_WithEmptyNewStores_ShouldReturnSetStoreActionWithUnset() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key"); + when(oldCustomer.getStores()).thenReturn(singletonList(keyReference1)); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(emptyList()) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of(emptyList())); + } + + @Test + void buildStoreUpdateActions_WithBothNullStores_ShouldNotReturnAction() { + + when(oldCustomer.getStores()).thenReturn(null); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(null) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildStoreUpdateActions_WithBothEmptyStores_ShouldNotReturnAction() { + + when(oldCustomer.getStores()).thenReturn(emptyList()); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(emptyList()) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions).isEmpty(); + } + + @Test + void buildStoreUpdateActions_WithNewStores_ShouldReturnAddStoreActions() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key1"); + when(oldCustomer.getStores()).thenReturn(singletonList(keyReference1)); + + final List> newStores = + asList(ResourceIdentifier.ofKey("store-key1"), ResourceIdentifier.ofKey("store-key2"), + ResourceIdentifier.ofKey("store-key3")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(AddStore.of(newStores.get(1)), AddStore.of(newStores.get(2))); + } + + @Test + void buildStoreUpdateActions_WithLessStores_ShouldReturnRemoveStoreActions() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key1"); + final KeyReference keyReference2 = mock(KeyReference.class); + when(keyReference2.getKey()).thenReturn("store-key2"); + final KeyReference keyReference3 = mock(KeyReference.class); + when(keyReference3.getKey()).thenReturn("store-key3"); + + final List> keyReferences = asList(keyReference1, keyReference2, keyReference3); + when(oldCustomer.getStores()).thenReturn(keyReferences); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(singletonList(ResourceIdentifier.ofKey("store-key1"))) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly( + RemoveStore.of(ResourceIdentifier.ofKey("store-key2")), + RemoveStore.of(ResourceIdentifier.ofKey("store-key3"))); + } + + @Test + void buildStoreUpdateActions_WithMixedStores_ShouldReturnSetStoresAction() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key1"); + final KeyReference keyReference3 = mock(KeyReference.class); + when(keyReference3.getKey()).thenReturn("store-key3"); + final KeyReference keyReference4 = mock(KeyReference.class); + when(keyReference4.getKey()).thenReturn("store-key4"); + + final List> keyReferences = asList(keyReference1, keyReference3, keyReference4); + when(oldCustomer.getStores()).thenReturn(keyReferences); + + final List> newStores = + asList(ResourceIdentifier.ofKey("store-key1"), ResourceIdentifier.ofKey("store-key2"), + ResourceIdentifier.ofKey("store-key3")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(SetStores.of( + asList(ResourceIdentifier.ofKey("store-key1"), + ResourceIdentifier.ofKey("store-key2"), + ResourceIdentifier.ofKey("store-key3")))); + } + + @Test + void buildStoreUpdateActions_WithNewStoresWithOnlyIdReference_ShouldReturnAddStoreActions() { + + final KeyReference keyReference1 = mock(KeyReference.class); + when(keyReference1.getKey()).thenReturn("store-key1"); + when(oldCustomer.getStores()).thenReturn(singletonList(keyReference1)); + + final List> newStores = + asList(ResourceIdentifier.ofKey("store-key1"), + ResourceIdentifier.ofId("store-id2"), + ResourceIdentifier.ofId("store-id3")); + + final CustomerDraft newCustomer = + CustomerDraftBuilder.of("email", "pass") + .stores(newStores) + .build(); + + final List> updateActions = + buildStoreUpdateActions(oldCustomer, newCustomer); + + assertThat(updateActions) + .isNotEmpty() + .containsExactly(AddStore.of(newStores.get(1)), AddStore.of(newStores.get(2))); + } +} diff --git a/src/test/java/com/commercetools/sync/services/impl/CustomerServiceImplTest.java b/src/test/java/com/commercetools/sync/services/impl/CustomerServiceImplTest.java new file mode 100644 index 0000000000..a5a1edf775 --- /dev/null +++ b/src/test/java/com/commercetools/sync/services/impl/CustomerServiceImplTest.java @@ -0,0 +1,198 @@ +package com.commercetools.sync.services.impl; + +import com.commercetools.sync.customers.CustomerSyncOptions; +import com.commercetools.sync.customers.CustomerSyncOptionsBuilder; +import io.sphere.sdk.client.BadGatewayException; +import io.sphere.sdk.client.BadRequestException; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customers.Customer; +import io.sphere.sdk.customers.CustomerDraft; +import io.sphere.sdk.customers.CustomerName; +import io.sphere.sdk.customers.CustomerSignInResult; +import io.sphere.sdk.customers.commands.CustomerCreateCommand; +import io.sphere.sdk.customers.commands.CustomerUpdateCommand; +import io.sphere.sdk.customers.commands.updateactions.ChangeName; +import io.sphere.sdk.utils.CompletableFutureUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.util.Collections.singletonList; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class CustomerServiceImplTest { + + private CustomerServiceImpl service; + private CustomerSyncOptions customerSyncOptions; + private List errorMessages; + private List errorExceptions; + + @BeforeEach + void setUp() { + errorMessages = new ArrayList<>(); + errorExceptions = new ArrayList<>(); + customerSyncOptions = CustomerSyncOptionsBuilder + .of(mock(SphereClient.class)) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorMessages.add(exception.getMessage()); + errorExceptions.add(exception.getCause()); + }) + .build(); + service = new CustomerServiceImpl(customerSyncOptions); + } + + @Test + void createCustomer_WithSuccessfulMockCtpResponse_ShouldReturnMock() { + final CustomerSignInResult resultMock = mock(CustomerSignInResult.class); + final Customer customerMock = mock(Customer.class); + when(customerMock.getId()).thenReturn("customerId"); + when(customerMock.getKey()).thenReturn("customerKey"); + when(resultMock.getCustomer()).thenReturn(customerMock); + + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn(completedFuture(resultMock)); + + final CustomerDraft draft = mock(CustomerDraft.class); + when(draft.getKey()).thenReturn("customerKey"); + final Optional customerOptional = service.createCustomer(draft).toCompletableFuture().join(); + + assertThat(customerOptional).isNotEmpty(); + assertThat(customerOptional).containsSame(customerMock); + verify(customerSyncOptions.getCtpClient()).execute(eq(CustomerCreateCommand.of(draft))); + } + + @Test + void createCustomer_WithUnSuccessfulMockResponse_ShouldNotCreate() { + final CustomerSignInResult resultMock = mock(CustomerSignInResult.class); + final Customer customerMock = mock(Customer.class); + when(customerMock.getId()).thenReturn("customerId"); + when(customerMock.getKey()).thenReturn("customerKey"); + when(resultMock.getCustomer()).thenReturn(customerMock); + + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn( + CompletableFutureUtils.failed(new BadRequestException("bad request"))); + + final CustomerDraft draft = mock(CustomerDraft.class); + when(draft.getKey()).thenReturn("customerKey"); + final Optional customerOptional = service.createCustomer(draft).toCompletableFuture().join(); + + assertThat(customerOptional).isEmpty(); + assertThat(errorExceptions).hasSize(1); + assertThat(errorExceptions.get(0)).isExactlyInstanceOf(BadRequestException.class); + assertThat(errorMessages).hasSize(1); + assertThat(errorMessages.get(0)).contains("Failed to create draft with key: 'customerKey'."); + verify(customerSyncOptions.getCtpClient()).execute(eq(CustomerCreateCommand.of(draft))); + } + + @Test + void createCustomer_WithDraftWithEmptyKey_ShouldNotCreate() { + final CustomerDraft draft = mock(CustomerDraft.class); + final Optional customerOptional = service.createCustomer(draft).toCompletableFuture().join(); + + assertThat(customerOptional).isEmpty(); + assertThat(errorExceptions).hasSize(1); + assertThat(errorMessages).hasSize(1); + assertThat(errorMessages.get(0)) + .contains("Failed to create draft with key: 'null'. Reason: Draft key is blank!"); + verifyNoInteractions(customerSyncOptions.getCtpClient()); + } + + @Test + void createCustomer_WithResponseIsNull_ShouldReturnEmpty() { + CustomerSignInResult resultMock = mock(CustomerSignInResult.class); + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn(completedFuture(resultMock)); + + final CustomerDraft draft = mock(CustomerDraft.class); + when(draft.getKey()).thenReturn("key"); + final Optional customerOptional = service.createCustomer(draft).toCompletableFuture().join(); + + assertThat(customerOptional).isEmpty(); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + } + + @Test + void updateCustomer_WithSuccessfulMockCtpResponse_ShouldReturnMock() { + Customer customer = mock(Customer.class); + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn(completedFuture(customer)); + + List> updateActions = + singletonList(ChangeName.of(CustomerName.of("title", "Max", "", "Mustermann"))); + Customer result = service.updateCustomer(customer, updateActions).toCompletableFuture().join(); + + assertThat(result).isSameAs(customer); + verify(customerSyncOptions.getCtpClient()).execute(eq(CustomerUpdateCommand.of(customer, updateActions))); + } + + @Test + void fetchCachedCustomerId_WithBlankKey_ShouldNotFetchCustomerId() { + Optional customerId = service.fetchCachedCustomerId("") + .toCompletableFuture() + .join(); + + assertThat(customerId).isEmpty(); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + verifyNoInteractions(customerSyncOptions.getCtpClient()); + } + + @Test + void fetchCachedCustomerId_WithCachedCustomer_ShouldFetchIdFromCache() { + service.keyToIdCache.put("key", "id"); + Optional customerId = service.fetchCachedCustomerId("key") + .toCompletableFuture() + .join(); + + assertThat(customerId).contains("id"); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + verifyNoInteractions(customerSyncOptions.getCtpClient()); + } + + @Test + void fetchCachedCustomerId_WithUnexpectedException_ShouldFail() { + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn( + CompletableFutureUtils.failed(new BadGatewayException("bad gateway"))); + + assertThat(service.fetchCachedCustomerId("key")) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(BadGatewayException.class); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + } + + @Test + void fetchCustomerByKey_WithUnexpectedException_ShouldFail() { + when(customerSyncOptions.getCtpClient().execute(any())).thenReturn( + CompletableFutureUtils.failed(new BadGatewayException("bad gateway"))); + + assertThat(service.fetchCustomerByKey("key")) + .hasFailedWithThrowableThat() + .isExactlyInstanceOf(BadGatewayException.class); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + } + + @Test + void fetchCustomerByKey_WithBlankKey_ShouldNotFetchCustomer() { + Optional customer = service.fetchCustomerByKey("") + .toCompletableFuture() + .join(); + + assertThat(customer).isEmpty(); + assertThat(errorExceptions).isEmpty(); + assertThat(errorMessages).isEmpty(); + verifyNoInteractions(customerSyncOptions.getCtpClient()); + } + +} \ No newline at end of file