diff --git a/README.md b/README.md index 03d53e5c03..63098d307f 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.0.0](https://img.shields.io/badge/Benchmarks-2.0.0-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) +[![Benchmarks 2.1.0](https://img.shields.io/badge/Benchmarks-2.1.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.0.0/) +[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.1.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 @@ -21,6 +21,7 @@ The library supports synchronising the following entities in commercetools - [CartDiscounts](/docs/usage/CART_DISCOUNT_SYNC.md) - [States](/docs/usage/STATE_SYNC.md) - [TaxCategories](/docs/usage/TAX_CATEGORY_SYNC.md) + - [CustomObjects](/docs/usage/CUSTOM_OBJECT_SYNC.md) ![commercetools-java-sync-final 001](https://user-images.githubusercontent.com/9512131/31230702-0f2255a6-a9e5-11e7-9412-04ed52641dde.png) @@ -36,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.0.0/) +- [Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.1.0/) - [Benchmarks](https://commercetools.github.io/commercetools-sync-java/benchmarks/) @@ -78,26 +79,26 @@ Here are the most popular ones: com.commercetools commercetools-sync-java - 2.0.0 + 2.1.0 ```` #### Gradle ````groovy -implementation 'com.commercetools:commercetools-sync-java:2.0.0' +implementation 'com.commercetools:commercetools-sync-java:2.1.0' ```` #### SBT ```` -libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.0.0" +libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.1.0" ```` #### Ivy ````xml - + ```` diff --git a/docs/README.md b/docs/README.md index d5b038b17a..fcb4f6328b 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.0.0](https://img.shields.io/badge/Benchmarks-2.0.0-orange.svg)](https://commercetools.github.io/commercetools-sync-java/benchmarks/) +[![Benchmarks 2.1.0](https://img.shields.io/badge/Benchmarks-2.1.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.0.0/) +[![Javadoc](http://javadoc-badge.appspot.com/com.commercetools/commercetools-sync-java.svg?label=Javadoc)](https://commercetools.github.io/commercetools-sync-java/v/2.1.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) @@ -35,6 +35,7 @@ The library supports synchronising the following entities in commercetools - [CartDiscounts](usage/CART_DISCOUNT_SYNC.md) - [States](usage/STATE_SYNC.md) - [TaxCategories](/docs/usage/TAX_CATEGORY_SYNC.md) + - [CustomObjects](/docs/usage/CUSTOM_OBJECT_SYNC.md) ![commercetools-java-sync-final 001](https://user-images.githubusercontent.com/9512131/31230702-0f2255a6-a9e5-11e7-9412-04ed52641dde.png) @@ -55,18 +56,18 @@ Here are the most popular ones: com.commercetools commercetools-sync-java - 2.0.0 + 2.1.0 ```` #### Gradle ````groovy -implementation 'com.commercetools:commercetools-sync-java:2.0.0' +implementation 'com.commercetools:commercetools-sync-java:2.1.0' ```` #### SBT ```` -libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.0.0" +libraryDependencies += "com.commercetools" % "commercetools-sync-java" % "2.1.0" ```` #### Ivy ````xml - + ```` diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 586f1b937b..c94fa586c4 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -33,8 +33,20 @@ [Commits](https://github.com/commercetools/commercetools-sync-java/compare/2.0.0...2.0.1) | [Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.0.1/) | [Jar](https://bintray.com/commercetools/maven/commercetools-sync-java/2.0.1) - ---> +--> + +### 2.1.0 - Sep 21, 2020 +[Commits](https://github.com/commercetools/commercetools-sync-java/compare/2.0.0...2.1.0) | +[Javadoc](https://commercetools.github.io/commercetools-sync-java/v/2.1.0/) | +[Jar](https://bintray.com/commercetools/maven/commercetools-sync-java/2.1.0) +- 🎉 **New Features** (2) + - **CustomObject Sync** - Added support for syncing custom objects between ctp projects. [#565](https://github.com/commercetools/commercetools-sync-java/issues/565) For more info how to use it please refer to [CustomObject usage doc](/docs/usage/CUSTOM_OBJECT_SYNC.md). + - **CustomObject Sync** - Exposed `CustomObjectSyncUtils#hasIdenticalValue` which determines whether update process is required after comparing a `CustomObject` and a `CustomObjectDraft`. [#565](https://github.com/commercetools/commercetools-sync-java/issues/565) + +- 🛠️ **Dependency Updates** (3) + - `org.ajoberstar.git-publish` `2.1.3` -> [`3.0.0`](https://github.com/ajoberstar/gradle-git-publish/releases/tag/3.0.0) + - `org.ajoberstar.grgit` `4.0.2` -> [`4.1.0`](https://github.com/ajoberstar/grgit/releases/tag/4.1.0) + - `mockito-junit-jupiter` `3.5.10` -> [`3.5.11`](https://github.com/mockito/mockito/releases/tag/v3.5.11) ### 2.0.0 - Sept 14, 2020 [Commits](https://github.com/commercetools/commercetools-sync-java/compare/1.9.1...2.0.0) | @@ -65,7 +77,7 @@ - `junit.jupiterApiVersion` `5.6.2` -> [`5.7.0`](https://github.com/junit-team/junit5/releases/tag/r5.7.0) - `mockito-junit-jupiter` `3.4.4` -> [`3.5.10`](https://github.com/mockito/mockito/releases/tag/v3.5.10) - `com.github.ben-manes.versions` `0.29.0` -> [`0.33.0`](https://github.com/ben-manes/gradle-versions-plugin/releases/tag/v0.33.0) - + ### 1.9.1 - Aug 5, 2020 [Commits](https://github.com/commercetools/commercetools-sync-java/compare/1.9.0...1.9.1) | [Javadoc](https://commercetools.github.io/commercetools-sync-java/v/1.9.1/) | diff --git a/docs/usage/CART_DISCOUNT_SYNC.md b/docs/usage/CART_DISCOUNT_SYNC.md index d18f1f34c6..cefa731158 100644 --- a/docs/usage/CART_DISCOUNT_SYNC.md +++ b/docs/usage/CART_DISCOUNT_SYNC.md @@ -32,8 +32,9 @@ 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.0.0/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.1.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 final List cartDiscountDrafts = CartDiscountReferenceResolutionUtils.mapToCartDiscountDrafts(cartDiscounts); ```` diff --git a/docs/usage/CATEGORY_SYNC.md b/docs/usage/CATEGORY_SYNC.md index 2e6d7a37d4..a114cb5f35 100644 --- a/docs/usage/CATEGORY_SYNC.md +++ b/docs/usage/CATEGORY_SYNC.md @@ -34,12 +34,12 @@ 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.0.0/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.1.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); - ```` - + ```` + 3. Create a `sphereClient` [as described here](IMPORTANT_USAGE_TIPS.md#sphereclient-creation). 4. After the `sphereClient` is set up, a `CategorySyncOptions` should be built as follows: diff --git a/docs/usage/CUSTOM_OBJECT_SYNC.md b/docs/usage/CUSTOM_OBJECT_SYNC.md new file mode 100644 index 0000000000..08eab185d5 --- /dev/null +++ b/docs/usage/CUSTOM_OBJECT_SYNC.md @@ -0,0 +1,70 @@ +# Custom Object Sync + +Module used for importing/syncing CustomObject into a commercetools project. +It also provides utilities for correlating a custom object to a given custom object draft based on the +comparison of a [CustomObject](https://docs.commercetools.com/http-api-projects-custom-objects#customobject) +against a [CustomObjectDraft](https://docs.commercetools.com/http-api-projects-custom-objects#customobjectdraft). + + + + + +- [Usage](#usage) + - [Sync list of CustomObjectDrafts](#sync-list-of-customobjectdrafts) + - [Prerequisites](#prerequisites) + - [Running the sync](#running-the-sync) + - [More examples of how to use the sync](#more-examples-of-how-to-use-the-sync) + + + +## Usage + +### Sync list of CustomObjectDrafts + +#### Prerequisites +1. The sync expects a list of `CustomObjectDraft`s that have their `key` and `container` fields set to be matched with +custom objects in the target CTP project. Therefore, the custom objects in the target project are expected to have the +same `key` and `container` fields set, otherwise they won't be matched. + +2. Create a `sphereClient` [as described here](IMPORTANT_USAGE_TIPS.md#sphereclient-creation). + +3. After the `sphereClient` is set up, a `CustomObjectSyncOptions` should be be built as follows: +````java +// instantiating a CustomObjectSyncOptions +final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(sphereClient).build(); +```` + +[More information about Sync Options](SYNC_OPTIONS.md). + +#### Running the sync +After all the aforementioned points in the previous section have been fulfilled, to run the sync: +````java +// instantiating a CustomObjectSync +final CustomObjectSync customObjectSync = new CustomObjectSync(customObjectSyncOptions); + +// execute the sync on your list of custom object drafts +CompletionStage syncStatisticsStage = customObjectSync.sync(customObjectDrafts); +```` +The result of the completing the `syncStatisticsStage` in the previous code snippet contains a `CustomObjectSyncStatistics` +which contains all the stats of the sync process; which includes a report message, the total number of updated, created, +failed, processed custom objects and the processing time of the last sync batch in different time units and in a +human-readable format. + +````java +final CustomObjectSyncStatistics stats = syncStatisticsStage.toCompletebleFuture().join(); +stats.getReportMessage(); +/*"Summary: 2000 custom objects were processed in total (1000 created, 995 updated, 5 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/customobjects/CustomObjectSyncIT.java). + +*Make sure to read the [Important Usage Tips](IMPORTANT_USAGE_TIPS.md) for optimal performance.* + +More examples of those utils for different custom objects can be found [here](https://github.com/commercetools/commercetools-sync-java/tree/master/src/test/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtilsTest.java). diff --git a/docs/usage/IMPORTANT_USAGE_TIPS.md b/docs/usage/IMPORTANT_USAGE_TIPS.md index 414dbebd5e..444a993add 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.0.0/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.1.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 fc6532365e..0840f13bc1 100644 --- a/docs/usage/INVENTORY_SYNC.md +++ b/docs/usage/INVENTORY_SYNC.md @@ -32,7 +32,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.0.0/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.1.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 8bebe3e358..f4a7aaa6ec 100644 --- a/docs/usage/PRODUCT_SYNC.md +++ b/docs/usage/PRODUCT_SYNC.md @@ -38,8 +38,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.0.0/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.1.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); @@ -47,7 +46,7 @@ order for the sync to resolve the actual ids of those references, those `key`s h > Note: Some references in the product like `state`, `customerGroup` of prices, and variant attributes with type `reference` do not support the `ResourceIdentifier` yet, for those references you need to provide the `key` value on the `id` field of the reference. This means that calling `getId()` on the reference would return its `key`. - > + 4. Create a `sphereClient` [as described here](IMPORTANT_USAGE_TIPS.md#sphereclient-creation). 5. After the `sphereClient` is set up, a `ProductSyncOptions` should be built as follows: diff --git a/docs/usage/PRODUCT_TYPE_SYNC.md b/docs/usage/PRODUCT_TYPE_SYNC.md index c67599a6e0..7772bb76ec 100644 --- a/docs/usage/PRODUCT_TYPE_SYNC.md +++ b/docs/usage/PRODUCT_TYPE_SYNC.md @@ -38,7 +38,8 @@ 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.0.0/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.1.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 // Puts the keys in the reference id fields to prepare for reference resolution diff --git a/docs/usage/QUICK_START.md b/docs/usage/QUICK_START.md index 9692e0b9ee..64feaa1952 100644 --- a/docs/usage/QUICK_START.md +++ b/docs/usage/QUICK_START.md @@ -37,7 +37,7 @@ com.commercetools commercetools-sync-java - 2.0.0 + 2.1.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.0.0' +implementation 'com.commercetools:commercetools-sync-java:2.1.0' ```` ### 2. Setup Syncing Options diff --git a/mkdocs.yml b/mkdocs.yml index 420e0647ba..361eaec1fe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,10 +55,11 @@ nav: - CartDiscount Sync: usage/CART_DISCOUNT_SYNC.md - TaxCategory Sync: usage/TAX_CATEGORY_SYNC.md - State Sync: usage/STATE_SYNC.md + - CustomObject Sync: usage/CUSTOM_OBJECT_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.0.0/ + - Javadoc: https://commercetools.github.io/commercetools-sync-java/v/2.1.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/externalsource/customobjects/CustomObjectSyncIT.java b/src/integration-test/java/com/commercetools/sync/integration/externalsource/customobjects/CustomObjectSyncIT.java new file mode 100644 index 0000000000..1727b99c36 --- /dev/null +++ b/src/integration-test/java/com/commercetools/sync/integration/externalsource/customobjects/CustomObjectSyncIT.java @@ -0,0 +1,285 @@ +package com.commercetools.sync.integration.externalsource.customobjects; + +import com.commercetools.sync.customobjects.CustomObjectSync; +import com.commercetools.sync.customobjects.CustomObjectSyncOptions; +import com.commercetools.sync.customobjects.CustomObjectSyncOptionsBuilder; +import com.commercetools.sync.customobjects.helpers.CustomObjectCompositeIdentifier; +import com.commercetools.sync.customobjects.helpers.CustomObjectSyncStatistics; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.sphere.sdk.client.BadGatewayException; +import io.sphere.sdk.client.BadRequestException; +import io.sphere.sdk.client.ConcurrentModificationException; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customobjects.CustomObjectDraft; +import io.sphere.sdk.customobjects.commands.CustomObjectUpsertCommand; +import io.sphere.sdk.customobjects.queries.CustomObjectQuery; +import io.sphere.sdk.queries.PagedQueryResult; + +import io.sphere.sdk.utils.CompletableFutureUtils; +import org.assertj.core.api.Assertions; +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.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics.assertThat; +import static com.commercetools.sync.integration.commons.utils.CustomObjectITUtils.createCustomObject; +import static com.commercetools.sync.integration.commons.utils.CustomObjectITUtils.deleteCustomObject; +import static com.commercetools.sync.integration.commons.utils.SphereClientUtils.CTP_TARGET_CLIENT; +import static java.lang.String.format; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class CustomObjectSyncIT { + private ObjectNode customObject1Value; + + @BeforeEach + void setup() { + deleteCustomObject(CTP_TARGET_CLIENT, "key1", "container1"); + deleteCustomObject(CTP_TARGET_CLIENT, "key2", "container2"); + customObject1Value = + JsonNodeFactory.instance.objectNode().put("name", "value1"); + + createCustomObject(CTP_TARGET_CLIENT, "key1", "container1", customObject1Value); + } + + @AfterAll + static void tearDown() { + deleteCustomObject(CTP_TARGET_CLIENT, "key1", "container1"); + deleteCustomObject(CTP_TARGET_CLIENT, "key2", "container2"); + } + + @Test + void sync_withNewCustomObject_shouldCreateCustomObject() { + + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of( + CTP_TARGET_CLIENT).build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(customObjectSyncOptions); + + final ObjectNode customObject2Value = + JsonNodeFactory.instance.objectNode().put("name", "value1"); + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container2", + "key2", customObject2Value); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 1, 0, 0); + } + + @Test + void sync_withExistingCustomObjectThatHasDifferentValue_shouldUpdateCustomObject() { + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of( + CTP_TARGET_CLIENT).build(); + final CustomObjectSync customObjectSync = new CustomObjectSync(customObjectSyncOptions); + + final ObjectNode customObject2Value = + JsonNodeFactory.instance.objectNode().put("name", "value2"); + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container1", + "key1", customObject2Value); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 1, 0); + } + + @Test + void sync_withExistingCustomObjectThatHasSameValue_shouldNotUpdateCustomObject() { + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container1", + "key1", customObject1Value); + + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of( + CTP_TARGET_CLIENT).build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(customObjectSyncOptions); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 0, 0); + } + + @Test + void sync_withChangedCustomObjectAndConcurrentModificationException_shouldRetryAndUpdateCustomObject() { + final SphereClient spyClient = spy(CTP_TARGET_CLIENT); + + final CustomObjectUpsertCommand customObjectUpsertCommand = any(CustomObjectUpsertCommand.class); + when(spyClient.execute(customObjectUpsertCommand)) + .thenReturn(CompletableFutureUtils.exceptionallyCompletedFuture(new ConcurrentModificationException())) + .thenCallRealMethod(); + + final ObjectNode newCustomObjectValue = JsonNodeFactory.instance.objectNode().put("name", "value2"); + List errorCallBackMessages = new ArrayList<>(); + List warningCallBackMessages = new ArrayList<>(); + List errorCallBackExceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyOptions = CustomObjectSyncOptionsBuilder + .of(spyClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(spyOptions); + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container1", + "key1", newCustomObjectValue); + + //test + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 1, 0); + Assertions.assertThat(errorCallBackExceptions).isEmpty(); + Assertions.assertThat(errorCallBackMessages).isEmpty(); + Assertions.assertThat(warningCallBackMessages).isEmpty(); + } + + @Test + void sync_withChangedCustomObjectWithBadGatewayExceptionInsideUpdateRetry_shouldFailToUpdate() { + final SphereClient spyClient = spy(CTP_TARGET_CLIENT); + + final CustomObjectUpsertCommand upsertCommand = any(CustomObjectUpsertCommand.class); + when(spyClient.execute(upsertCommand)) + .thenReturn(CompletableFutureUtils.exceptionallyCompletedFuture(new ConcurrentModificationException())) + .thenCallRealMethod(); + + final CustomObjectQuery customObjectQuery = any(CustomObjectQuery.class); + when(spyClient.execute(customObjectQuery)) + .thenCallRealMethod() + .thenReturn(CompletableFutureUtils.exceptionallyCompletedFuture(new BadGatewayException())); + + final ObjectNode newCustomObjectValue = JsonNodeFactory.instance.objectNode().put("name", "value2"); + List errorCallBackMessages = new ArrayList<>(); + List errorCallBackExceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyOptions = CustomObjectSyncOptionsBuilder + .of(spyClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(spyOptions); + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container1", + "key1", newCustomObjectValue); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 0, 1); + Assertions.assertThat(errorCallBackMessages).hasSize(1); + Assertions.assertThat(errorCallBackExceptions).hasSize(1); + Assertions.assertThat(errorCallBackMessages.get(0)).contains( + format("Failed to update custom object with key: '%s'. Reason: Failed to fetch from CTP while retrying " + + "after concurrency modification.", CustomObjectCompositeIdentifier.of(customObjectDraft))); + } + + @Test + void sync_withConcurrentModificationExceptionAndUnexpectedDelete_shouldFailToReFetchAndUpdate() { + + final SphereClient spyClient = spy(CTP_TARGET_CLIENT); + + final CustomObjectUpsertCommand customObjectUpsertCommand = any(CustomObjectUpsertCommand.class); + when(spyClient.execute(customObjectUpsertCommand)) + .thenReturn(CompletableFutureUtils.exceptionallyCompletedFuture(new ConcurrentModificationException())) + .thenCallRealMethod(); + + final CustomObjectQuery customObjectQuery = any(CustomObjectQuery.class); + + when(spyClient.execute(customObjectQuery)) + .thenCallRealMethod() + .thenReturn(CompletableFuture.completedFuture(PagedQueryResult.empty())); + + final ObjectNode newCustomObjectValue = JsonNodeFactory.instance.objectNode().put("name", "value2"); + List errorCallBackMessages = new ArrayList<>(); + List errorCallBackExceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyOptions = CustomObjectSyncOptionsBuilder + .of(spyClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(spyOptions); + + final CustomObjectDraft customObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container1", + "key1", newCustomObjectValue); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(customObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 0, 1); + Assertions.assertThat(errorCallBackMessages).hasSize(1); + Assertions.assertThat(errorCallBackExceptions).hasSize(1); + + Assertions.assertThat(errorCallBackMessages.get(0)).contains( + format("Failed to update custom object with key: '%s'. Reason: Not found when attempting to fetch while" + + " retrying after concurrency modification.", CustomObjectCompositeIdentifier.of(customObjectDraft))); + } + + @Test + void sync_withNewCustomObjectAndBadRequest_shouldNotCreateButHandleError() { + + final SphereClient spyClient = spy(CTP_TARGET_CLIENT); + + final CustomObjectUpsertCommand upsertCommand = any(CustomObjectUpsertCommand.class); + when(spyClient.execute(upsertCommand)) + .thenReturn(CompletableFutureUtils.exceptionallyCompletedFuture(new BadRequestException("bad request"))) + .thenCallRealMethod(); + + final ObjectNode newCustomObjectValue = JsonNodeFactory.instance.objectNode().put("name", "value2"); + final CustomObjectDraft newCustomObjectDraft = CustomObjectDraft.ofUnversionedUpsert("container2", + "key2", newCustomObjectValue); + + List errorCallBackMessages = new ArrayList<>(); + List errorCallBackExceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyOptions = CustomObjectSyncOptionsBuilder + .of(spyClient) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorCallBackMessages.add(exception.getMessage()); + errorCallBackExceptions.add(exception.getCause()); + }) + .build(); + + final CustomObjectSync customObjectSync = new CustomObjectSync(spyOptions); + + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(Collections.singletonList(newCustomObjectDraft)) + .toCompletableFuture().join(); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 0, 1); + Assertions.assertThat(errorCallBackMessages).hasSize(1); + Assertions.assertThat(errorCallBackExceptions).hasSize(1); + Assertions.assertThat(errorCallBackExceptions.get(0)).isExactlyInstanceOf(CompletionException.class); + Assertions.assertThat(errorCallBackExceptions.get(0).getCause()).isExactlyInstanceOf(BadRequestException.class); + Assertions.assertThat(errorCallBackMessages.get(0)).contains( + format("Failed to create custom object with key: '%s'.", + CustomObjectCompositeIdentifier.of(newCustomObjectDraft))); + } +} \ No newline at end of file diff --git a/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomObjectServiceImplIT.java b/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomObjectServiceImplIT.java index 9ea526c38e..8c85e3c00c 100644 --- a/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomObjectServiceImplIT.java +++ b/src/integration-test/java/com/commercetools/sync/integration/services/impl/CustomObjectServiceImplIT.java @@ -1,6 +1,5 @@ package com.commercetools.sync.integration.services.impl; - import com.commercetools.sync.customobjects.CustomObjectSyncOptions; import com.commercetools.sync.customobjects.CustomObjectSyncOptionsBuilder; import com.commercetools.sync.customobjects.helpers.CustomObjectCompositeIdentifier; @@ -165,8 +164,6 @@ void fetchMatchingCustomObjects_WithDifferentExistingCombinationOfKeysAndContain OLD_CUSTOM_OBJECT_KEY + "_2", OLD_CUSTOM_OBJECT_CONTAINER + "_2"); } - - @Test void fetchMatchingCustomObjectsByCompositeIdentifiers_WithBadGateWayExceptionAlways_ShouldFail() { // Mock sphere client to return BadGatewayException on any request. @@ -262,8 +259,6 @@ void upsertCustomObject_WithValidCustomObject_ShouldCreateCustomObjectAndCacheId verify(spyClient, times(0)).execute(any(CustomObjectQuery.class)); } - - @Test void upsertCustomObject_WithDuplicateKeyAndContainerInCompositeIdentifier_ShouldUpdateValue() { //preparation @@ -284,5 +279,4 @@ void upsertCustomObject_WithDuplicateKeyAndContainerInCompositeIdentifier_Should assertThat(result.get().getKey()).isEqualTo(OLD_CUSTOM_OBJECT_KEY); } - } diff --git a/src/main/java/com/commercetools/sync/commons/BaseSyncOptions.java b/src/main/java/com/commercetools/sync/commons/BaseSyncOptions.java index 8099e76b39..aa9370b8db 100644 --- a/src/main/java/com/commercetools/sync/commons/BaseSyncOptions.java +++ b/src/main/java/com/commercetools/sync/commons/BaseSyncOptions.java @@ -18,13 +18,13 @@ /** * @param Resource Draft (e.g. {@link io.sphere.sdk.products.ProductDraft}, - * {@link io.sphere.sdk.categories.CategoryDraft}, etc.. + * {@link io.sphere.sdk.categories.CategoryDraft}, etc.. * @param Resource (e.g. {@link io.sphere.sdk.products.Product}, {@link io.sphere.sdk.categories.Category}, etc.. */ public class BaseSyncOptions { private final SphereClient ctpClient; private final QuadConsumer, Optional, List>> - errorCallback; + errorCallback; private final TriConsumer, Optional> warningCallback; private int batchSize; private final TriFunction>, V, U, List>> beforeUpdateCallback; @@ -33,7 +33,7 @@ public class BaseSyncOptions { protected BaseSyncOptions( @Nonnull final SphereClient ctpClient, @Nullable final QuadConsumer, Optional, List>> - errorCallback, + errorCallback, @Nullable final TriConsumer, Optional> warningCallback, final int batchSize, @Nullable final TriFunction>, V, U, List>> @@ -64,9 +64,9 @@ public SphereClient getCtpClient() { * the sync process that represents an error. * * @return the {@code errorCallback} {@link QuadConsumer}<{@link SyncException}, - * {@link Optional}<{@code V}>, {@link Optional}<{@code U}>, - * {@link List}<{@link UpdateAction}<{@code U}>> function set to - * {@code this} {@link BaseSyncOptions} + * {@link Optional}<{@code V}>, {@link Optional}<{@code U}>, + * {@link List}<{@link UpdateAction}<{@code U}>> function set to + * {@code this} {@link BaseSyncOptions} */ @Nullable public QuadConsumer, Optional, List>> getErrorCallback() { @@ -80,8 +80,8 @@ public QuadConsumer, Optional, List, Optional> getWarningCallback() { @@ -94,12 +94,12 @@ public TriConsumer, Optional> getWarningCallback() * {@link BaseSyncOptions}. If there {@code warningCallback} is null, this * method does nothing. * - * @param exception the exception to supply to the {@code warningCallback} function. - * @param oldResource the old resource that is being compared to the new draft. + * @param exception the exception to supply to the {@code warningCallback} function. + * @param oldResource the old resource that is being compared to the new draft. * @param newResourceDraft the new resource draft that is being compared to the old resource. */ public void applyWarningCallback(@Nonnull final SyncException exception, @Nullable final U oldResource, - @Nullable final V newResourceDraft) { + @Nullable final V newResourceDraft) { if (this.warningCallback != null) { this.warningCallback.accept(exception, Optional.ofNullable(newResourceDraft), Optional.ofNullable(oldResource)); @@ -111,13 +111,15 @@ public void applyWarningCallback(@Nonnull final SyncException exception, @Nullab * which is set to {@code this} instance of the {@link BaseSyncOptions}. If there {@code errorCallback} is null, * this method does nothing. * - * @param exception {@link Throwable} instance to supply as first param to the {@code errorCallback} function. - * @param oldResource the old resource that is being compared to the new draft. + * @param exception {@link Throwable} instance to supply as first param to the {@code errorCallback} + * function. + * @param oldResource the old resource that is being compared to the new draft. * @param newResourceDraft the new resource draft that is being compared to the old resource. - * @param updateActions the list of update actions. + * @param updateActions the list of update actions. */ public void applyErrorCallback(@Nonnull final SyncException exception, @Nullable final U oldResource, - @Nullable final V newResourceDraft, @Nullable final List> updateActions) { + @Nullable final V newResourceDraft, + @Nullable final List> updateActions) { if (this.errorCallback != null) { this.errorCallback.accept(exception, Optional.ofNullable(newResourceDraft), Optional.ofNullable(oldResource), updateActions); @@ -125,7 +127,6 @@ public void applyErrorCallback(@Nonnull final SyncException exception, @Nullable } /** - * * @param syncException {@link Throwable} instance to supply as first param to the {@code errorCallback} function. * @see #applyErrorCallback(SyncException exception, Object oldResource, Object newResource, List updateActions) */ @@ -134,7 +135,6 @@ public void applyErrorCallback(@Nonnull final SyncException syncException) { } /** - * * @param errorMessage the error message to supply as part of first param to the {@code errorCallback} function. * @see #applyErrorCallback(SyncException exception, Object oldResource, Object newResource, List updateActions) */ @@ -148,9 +148,10 @@ public void applyErrorCallback(@Nonnull final String errorMessage) { * batches and then processed. It allows to reduce the query size for fetching all resources processed in one * batch. * E.g. value of 30 means that 30 entries from input list would be accumulated and one API call will be performed - * for fetching entries responding to them. Then comparision and sync are performed. + * for fetching entries responding to them. Then comparison and sync are performed. * *

This batch size is set to 30 by default. + * * @return option that indicates capacity of batch of resources to process. */ public int getBatchSize() { @@ -164,8 +165,8 @@ public int getBatchSize() { * generated list of update actions to produce a resultant list after the filter function has been applied. * * @return the {@code beforeUpdateCallback} {@link TriFunction}<{@link List}<{@link UpdateAction}< - * {@code U}>>, {@code V}, {@code U}, {@link List}<{@link UpdateAction}<{@code U}>>> - * function set to {@code this} {@link BaseSyncOptions}. + * {@code U}>>, {@code V}, {@code U}, {@link List}<{@link UpdateAction}<{@code U}>>> + * function set to {@code this} {@link BaseSyncOptions}. */ @Nullable public TriFunction>, V, U, List>> getBeforeUpdateCallback() { @@ -179,7 +180,7 @@ public TriFunction>, V, U, List>> getBefore * function has been applied. * * @return the {@code beforeUpdateCallback} {@link Function}<{@code V}, {@link Optional}<{@code V}>> - * function set to {@code this} {@link BaseSyncOptions}. + * function set to {@code this} {@link BaseSyncOptions}. */ @Nullable public Function getBeforeCreateCallback() { @@ -193,13 +194,13 @@ public Function getBeforeCreateCallback() { * is null or {@code updateActions} is empty, this method does nothing to the supplied list of {@code updateActions} * and returns the same list. If the result of the callback is null, an empty list is returned. * - * @param updateActions the list of update actions to apply the {@code beforeUpdateCallback} function on. + * @param updateActions the list of update actions to apply the {@code beforeUpdateCallback} function on. * @param newResourceDraft the new resource draft that is being compared to the old resource. - * @param oldResource the old resource that is being compared to the new draft. + * @param oldResource the old resource that is being compared to the new draft. * @return a list of update actions after applying the {@code beforeUpdateCallback} function on. If the - * {@code beforeUpdateCallback} function is null or {@code updateActions} is empty, the supplied list of - * {@code updateActions} is returned as is. If the return of the callback is null, an empty list is - * returned. + * {@code beforeUpdateCallback} function is null or {@code updateActions} is empty, the supplied list of + * {@code updateActions} is returned as is. If the return of the callback is null, an empty list is + * returned. */ @Nonnull public List> applyBeforeUpdateCallback(@Nonnull final List> updateActions, @@ -224,12 +225,12 @@ public List> applyBeforeUpdateCallback(@Nonnull final List applyBeforeCreateCallback(@Nonnull final V newResourceDraft) { return ofNullable( - beforeCreateCallback != null ? beforeCreateCallback.apply(newResourceDraft) : newResourceDraft); + beforeCreateCallback != null ? beforeCreateCallback.apply(newResourceDraft) : newResourceDraft); } } diff --git a/src/main/java/com/commercetools/sync/commons/utils/AssetsUpdateActionUtils.java b/src/main/java/com/commercetools/sync/commons/utils/AssetsUpdateActionUtils.java index 96ec13c504..bb59e8a1a8 100644 --- a/src/main/java/com/commercetools/sync/commons/utils/AssetsUpdateActionUtils.java +++ b/src/main/java/com/commercetools/sync/commons/utils/AssetsUpdateActionUtils.java @@ -150,7 +150,6 @@ private static List> buildAssetsUpdateAc // It is important to have a changeAssetOrder action before an addAsset action, since changeAssetOrder requires // asset ids for sorting them, and new assets don't have ids yet since they are generated // by CTP after an asset is created. Therefore, the order of update actions must be: - // removeAsset → changeAssetOrder → addAsset //1. Remove or compare if matching. final List> updateActions = diff --git a/src/main/java/com/commercetools/sync/customobjects/CustomObjectSync.java b/src/main/java/com/commercetools/sync/customobjects/CustomObjectSync.java new file mode 100644 index 0000000000..39e526ad21 --- /dev/null +++ b/src/main/java/com/commercetools/sync/customobjects/CustomObjectSync.java @@ -0,0 +1,342 @@ +package com.commercetools.sync.customobjects; + +import com.commercetools.sync.commons.BaseSync; +import com.commercetools.sync.commons.exceptions.SyncException; +import com.commercetools.sync.customobjects.helpers.CustomObjectCompositeIdentifier; +import com.commercetools.sync.customobjects.helpers.CustomObjectSyncStatistics; +import com.commercetools.sync.customobjects.utils.CustomObjectSyncUtils; +import com.commercetools.sync.services.CustomObjectService; +import com.commercetools.sync.services.impl.CustomObjectServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; +import org.apache.commons.lang3.tuple.ImmutablePair; + +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 com.commercetools.sync.commons.utils.SyncUtils.batchElements; +import static java.lang.String.format; +import static java.util.Optional.ofNullable; +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 custom object drafts with the corresponding custom objects in the CTP project. + */ +public class CustomObjectSync extends BaseSync, + CustomObjectSyncStatistics, CustomObjectSyncOptions> { + + private static final String CTP_CUSTOM_OBJECT_FETCH_FAILED = + "Failed to fetch existing custom objects with keys: '%s'."; + private static final String CTP_CUSTOM_OBJECT_UPDATE_FAILED = + "Failed to update custom object with key: '%s'. Reason: %s"; + private static final String CTP_CUSTOM_OBJECT_CREATE_FAILED = + "Failed to create custom object with key: '%s'. Reason: %s"; + private static final String CUSTOM_OBJECT_DRAFT_IS_NULL = "Failed to process null custom object draft."; + + private final CustomObjectService customObjectService; + + public CustomObjectSync(@Nonnull final CustomObjectSyncOptions syncOptions) { + this(syncOptions, new CustomObjectServiceImpl(syncOptions)); + } + + /** + * Takes a {@link CustomObjectSyncOptions} and a {@link CustomObjectService} instances to instantiate + * a new {@link CustomObjectSync} instance that could be used to sync customObject drafts in the CTP project + * specified in the injected {@link CustomObjectSyncOptions} instance. + * + *

NOTE: This constructor is mainly to be used for tests where the services can be mocked and passed to. + * + * @param syncOptions the container of all the options of the sync process including the CTP project + * client and/or configuration and other sync-specific options. + * @param customObjectService the custom object service which is responsible for fetching/caching the + */ + CustomObjectSync( + @Nonnull final CustomObjectSyncOptions syncOptions, + @Nonnull final CustomObjectService customObjectService) { + + super(new CustomObjectSyncStatistics(), syncOptions); + this.customObjectService = customObjectService; + } + + /** + * Iterates through the whole {@code customObjectDrafts} list and accumulates its valid drafts to batches. + * Every batch is then processed by {@link CustomObjectSync#processBatch(List)}. + * + *

Inherited doc: + * {@inheritDoc} + * + * @param customObjectDrafts {@link List} of {@link CustomObjectDraft}'s that would be synced into CTP project. + * @return {@link CompletionStage} with {@link CustomObjectSyncStatistics} holding statistics of all sync + * processes performed by this sync instance. + */ + protected CompletionStage process( + @Nonnull final List> customObjectDrafts) { + final List>> batches = batchElements( + customObjectDrafts, syncOptions.getBatchSize()); + return syncBatches(batches, CompletableFuture.completedFuture(statistics)); + } + + /** + * This method first creates a new {@link Set} of valid {@link CustomObjectDraft} elements. For more on the rules of + * validation, check: {@link CustomObjectSync#validateDraft(CustomObjectDraft)}. Using the resulting set of + * {@code validCustomObjectDrafts}, the matching custom objects in the target CTP project are fetched then the + * method {@link CustomObjectSync#syncBatch(Set, Set)} is called to perform the sync (update or create + * requests accordingly) on the target project. + * + *

In case of error during of fetching of existing custom objects, the error callback will be triggered. + * And the sync process would stop for the given batch. + *

+ * + * @param batch batch of drafts that need to be synced + * @return a {@link CompletionStage} containing an instance + * of {@link CustomObjectSyncStatistics} which contains information about the result of syncing the supplied + * batch to the target project. + */ + protected CompletionStage processBatch( + @Nonnull final List> batch) { + + final Set> validCustomObjectDrafts = batch.stream().filter( + this::validateDraft).collect(toSet()); + + if (validCustomObjectDrafts.isEmpty()) { + statistics.incrementProcessed(batch.size()); + return completedFuture(statistics); + } else { + final Set identifiers = validCustomObjectDrafts.stream().map( + CustomObjectCompositeIdentifier::of).collect(toSet()); + + return customObjectService + .fetchMatchingCustomObjects(identifiers) + .handle(ImmutablePair::new) + .thenCompose(fetchResponse -> { + final Set> fetchedCustomObjects = fetchResponse.getKey(); + final Throwable exception = fetchResponse.getValue(); + + if (exception != null) { + final String errorMessage = format(CTP_CUSTOM_OBJECT_FETCH_FAILED, identifiers); + handleError(errorMessage, exception, identifiers.size()); + return CompletableFuture.completedFuture(null); + } else { + return syncBatch(fetchedCustomObjects, validCustomObjectDrafts); + } + }) + .thenApply(ignored -> { + statistics.incrementProcessed(batch.size()); + return statistics; + }); + } + } + + /** + * Checks if a draft is empty for further processing. If so, then returns {@code true}. Otherwise handles an error + * and returns {@code false}. A valid draft is a {@link CustomObjectDraft} object that is not {@code null}. + * + * @param draft nullable draft + * @return boolean that indicate if given {@code draft} is valid for sync + */ + private boolean validateDraft(@Nullable final CustomObjectDraft draft) { + if (draft == null) { + handleError(CUSTOM_OBJECT_DRAFT_IS_NULL, null, 1); + return false; + } + return true; + } + + /** + * Given a {@link String} {@code errorMessage} and a {@link Throwable} {@code exception}, this method calls the + * optional error callback specified in the {@code syncOptions} and updates the {@code statistics} instance by + * incrementing the total number of failed custom objects to sync. + * + * @param errorMessage The error message describing the reason(s) of failure. + * @param exception The exception that called caused the failure, if any. + * @param failedTimes The number of times that the failed custom objects counter is incremented. + */ + private void handleError(@Nonnull final String errorMessage, @Nullable final Throwable exception, + final int failedTimes) { + SyncException syncException = exception != null ? new SyncException(errorMessage, exception) + : new SyncException(errorMessage); + syncOptions.applyErrorCallback(syncException); + statistics.incrementFailed(failedTimes); + } + + /** + * Given a {@link String} {@code errorMessage} and a {@link Throwable} {@code exception}, this method calls the + * optional error callback specified in the {@code syncOptions} and updates the {@code statistics} instance by + * incrementing the total number of failed custom objects to sync. + * + * @param errorMessage The error message describing the reason(s) of failure. + * @param exception The exception that called caused the failure, if any. + * @param failedTimes The number of times that the failed custom objects counter is incremented. + * @param oldCustomObject existing custom object that could be updated. + * @param newCustomObjectDraft draft containing data that could differ from data in {@code oldCustomObject}. + */ + private void handleError(@Nonnull final String errorMessage, @Nullable final Throwable exception, + final int failedTimes, @Nullable final CustomObject oldCustomObject, + @Nullable final CustomObjectDraft newCustomObjectDraft) { + + SyncException syncException = exception != null ? new SyncException(errorMessage, exception) + : new SyncException(errorMessage); + syncOptions.applyErrorCallback(syncException, oldCustomObject, newCustomObjectDraft, null); + statistics.incrementFailed(failedTimes); + } + + /** + * Given a set of custom object drafts, attempts to sync the drafts with the existing custom objects in the CTP + * project. The custom object and the draft are considered to match if they have the same key and container. + * + * @param oldCustomObjects old custom objects. + * @param newCustomObjectDrafts drafts that need to be synced. + * @return a {@link CompletionStage} which contains an empty result after execution of the update + */ + @Nonnull + private CompletionStage syncBatch( + @Nonnull final Set> oldCustomObjects, + @Nonnull final Set> newCustomObjectDrafts) { + + final Map> oldCustomObjectMap = + oldCustomObjects.stream().collect( + toMap(customObject -> CustomObjectCompositeIdentifier.of( + customObject.getKey(), customObject.getContainer()).toString(), identity())); + + return CompletableFuture.allOf(newCustomObjectDrafts + .stream() + .map(newCustomObjectDraft -> { + final CustomObject oldCustomObject = oldCustomObjectMap.get( + CustomObjectCompositeIdentifier.of(newCustomObjectDraft).toString()); + return ofNullable(oldCustomObject) + .map(customObject -> updateCustomObject(oldCustomObject, newCustomObjectDraft)) + .orElseGet(() -> applyCallbackAndCreate(newCustomObjectDraft)); + }) + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + } + + /** + * Given a custom object draft, this method applies the beforeCreateCallback and then issues a create request to the + * CTP project to create the corresponding CustomObject. + * + * @param customObjectDraft the custom object draft to create the custom object from. + * @return a {@link CompletionStage} which contains created custom object after success execution of the create. + * Otherwise it contains an empty result in case of failure. + */ + @Nonnull + private CompletionStage>> applyCallbackAndCreate( + @Nonnull final CustomObjectDraft customObjectDraft) { + + return syncOptions + .applyBeforeCreateCallback(customObjectDraft) + .map(draft -> customObjectService + .upsertCustomObject(draft) + .thenApply(customObjectOptional -> { + if (customObjectOptional.isPresent()) { + statistics.incrementCreated(); + } else { + statistics.incrementFailed(); + } + return customObjectOptional; + }).exceptionally(sphereException -> { + final String errorMessage = + format(CTP_CUSTOM_OBJECT_CREATE_FAILED, + CustomObjectCompositeIdentifier.of(customObjectDraft).toString(), + sphereException.getMessage()); + handleError(errorMessage, sphereException, 1, + null, customObjectDraft); + return Optional.empty(); + }) + ).orElse(completedFuture(Optional.empty())); + } + + /** + * Given an existing {@link CustomObject} and a new {@link CustomObjectDraft}, the method first checks whether + * existing {@link CustomObject} and a new {@link CustomObjectDraft} are identical. If so, the method aborts update + * the new draft and return an empty result, otherwise a request is made to CTP to update the existing custom + * object. + * + *

The {@code statistics} instance is updated accordingly to whether the CTP request was carried + * out successfully or not. If an exception was thrown on executing the request to CTP,the error handling method + * is called. + * + * @param oldCustomObject existing custom object that could be updated. + * @param newCustomObject draft containing data that could differ from data in {@code oldCustomObject}. + * @return a {@link CompletionStage} which contains an empty result after execution of the update. + */ + @Nonnull + private CompletionStage>> updateCustomObject( + @Nonnull final CustomObject oldCustomObject, + @Nonnull final CustomObjectDraft newCustomObject) { + + if (!CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObject)) { + return customObjectService + .upsertCustomObject(newCustomObject) + .handle(ImmutablePair::new) + .thenCompose(updatedResponseEntry -> { + final Optional> updateCustomObjectOptional = updatedResponseEntry.getKey(); + final Throwable sphereException = updatedResponseEntry.getValue(); + if (sphereException != null) { + return executeSupplierIfConcurrentModificationException(sphereException, + () -> fetchAndUpdate(oldCustomObject, newCustomObject), + () -> { + final String errorMessage = + format(CTP_CUSTOM_OBJECT_UPDATE_FAILED, + CustomObjectCompositeIdentifier.of(newCustomObject).toString(), + sphereException.getMessage()); + handleError(errorMessage, sphereException, 1, + oldCustomObject, newCustomObject); + return CompletableFuture.completedFuture(Optional.empty()); + }); + } else { + statistics.incrementUpdated(); + return CompletableFuture.completedFuture(Optional.of(updateCustomObjectOptional.get())); + } + + }); + } + return completedFuture(Optional.empty()); + } + + @Nonnull + private CompletionStage>> fetchAndUpdate( + @Nonnull final CustomObject oldCustomObject, + @Nonnull final CustomObjectDraft customObjectDraft) { + + final CustomObjectCompositeIdentifier identifier = CustomObjectCompositeIdentifier.of(oldCustomObject); + + return customObjectService + .fetchCustomObject(identifier) + .handle(ImmutablePair::new) + .thenCompose(fetchedResponseEntry -> { + final Optional> fetchedCustomObjectOptional = fetchedResponseEntry.getKey(); + final Throwable exception = fetchedResponseEntry.getValue(); + + if (exception != null) { + final String errorMessage = format(CTP_CUSTOM_OBJECT_UPDATE_FAILED, identifier.toString(), + "Failed to fetch from CTP while retrying after concurrency modification."); + handleError(errorMessage, exception, 1, oldCustomObject, customObjectDraft); + return CompletableFuture.completedFuture(Optional.empty()); + } + return fetchedCustomObjectOptional + .map(fetchedCustomObject -> updateCustomObject(fetchedCustomObject, customObjectDraft)) + .orElseGet(() -> { + final String errorMessage = + format(CTP_CUSTOM_OBJECT_UPDATE_FAILED, identifier.toString(), + "Not found when attempting to fetch while retrying " + + "after concurrency modification."); + handleError(errorMessage, null, 1, + oldCustomObject, customObjectDraft); + return CompletableFuture.completedFuture(null); + }); + + }); + } +} diff --git a/src/main/java/com/commercetools/sync/customobjects/CustomObjectSyncOptions.java b/src/main/java/com/commercetools/sync/customobjects/CustomObjectSyncOptions.java index c27eae1065..04b754fbab 100644 --- a/src/main/java/com/commercetools/sync/customobjects/CustomObjectSyncOptions.java +++ b/src/main/java/com/commercetools/sync/customobjects/CustomObjectSyncOptions.java @@ -5,29 +5,33 @@ import com.commercetools.sync.commons.utils.QuadConsumer; import com.commercetools.sync.commons.utils.TriConsumer; import com.commercetools.sync.commons.utils.TriFunction; +import com.fasterxml.jackson.databind.JsonNode; import io.sphere.sdk.client.SphereClient; import io.sphere.sdk.commands.UpdateAction; import io.sphere.sdk.customobjects.CustomObject; import io.sphere.sdk.customobjects.CustomObjectDraft; - import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.List; import java.util.Optional; import java.util.function.Function; -public final class CustomObjectSyncOptions extends BaseSyncOptions { +public final class CustomObjectSyncOptions extends BaseSyncOptions, + CustomObjectDraft> { CustomObjectSyncOptions( @Nonnull final SphereClient ctpClient, - @Nullable final QuadConsumer, Optional, - List>> errorCallBack, - @Nullable final TriConsumer, Optional> + @Nullable final QuadConsumer>, + Optional>, + List>>> errorCallBack, + @Nullable final TriConsumer>, + Optional>> warningCallBack, final int batchSize, - @Nullable final TriFunction>, CustomObjectDraft, CustomObject, - List>> beforeUpdateCallback, - @Nullable final Function beforeCreateCallback) { + @Nullable final TriFunction>>, CustomObjectDraft, + CustomObject, + List>>> beforeUpdateCallback, + @Nullable final Function, CustomObjectDraft> beforeCreateCallback) { super( ctpClient, errorCallBack, @@ -36,5 +40,4 @@ public final class CustomObjectSyncOptions extends BaseSyncOptions { + CustomObjectSyncOptions, CustomObject, CustomObjectDraft> { public static final int BATCH_SIZE_DEFAULT = 50; diff --git a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifier.java b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifier.java index 73b57e7e63..e5f9700b59 100644 --- a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifier.java +++ b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifier.java @@ -4,6 +4,7 @@ import io.sphere.sdk.customobjects.CustomObjectDraft; import javax.annotation.Nonnull; +import java.util.Objects; import static java.lang.String.format; @@ -19,19 +20,18 @@ public final class CustomObjectCompositeIdentifier { private final String container; private CustomObjectCompositeIdentifier(@Nonnull final String key, - @Nonnull final String container) { + @Nonnull final String container) { this.key = key; this.container = container; } - /** * Given a {@link CustomObjectDraft}, creates a {@link CustomObjectCompositeIdentifier} using the following fields * from the supplied {@link CustomObjectDraft}: *

    *
  1. {@link CustomObjectCompositeIdentifier#key}: key of {@link CustomObjectDraft#getKey()}
  2. *
  3. {@link CustomObjectCompositeIdentifier#container}: container of {@link CustomObjectDraft#getContainer()}
  4. - + * *
* * @param customObjectDraft a composite id is built using its fields. @@ -62,7 +62,7 @@ public static CustomObjectCompositeIdentifier of(@Nonnull final CustomObject cus * Given a {@link String} and {@link String}, creates a {@link CustomObjectCompositeIdentifier} using the following * fields from the supplied attributes. * - * @param key key of CustomerObject to build composite Id. + * @param key key of CustomerObject to build composite Id. * @param container container of CustomerObject to build composite Id. * @return a composite id comprised of the fields of the supplied {@code key} and {@code container}. */ @@ -83,4 +83,21 @@ public String getContainer() { public String toString() { return format("{key='%s', container='%s'}", key, container); } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CustomObjectCompositeIdentifier)) { + return false; + } + final CustomObjectCompositeIdentifier that = (CustomObjectCompositeIdentifier) obj; + return key.equals(that.key) && container.equals(that.container); + } + + @Override + public int hashCode() { + return Objects.hash(key, container); + } } diff --git a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java new file mode 100644 index 0000000000..3950da3c1c --- /dev/null +++ b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java @@ -0,0 +1,23 @@ +package com.commercetools.sync.customobjects.helpers; + +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: + * + *

"Summary: 2 custom objects were processed in total (0 created, 0 updated and 0 failed to sync)." + * + * @return a summary message of the custom objects sync statistics instance. + */ + @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; + } +} diff --git a/src/main/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtils.java b/src/main/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtils.java new file mode 100644 index 0000000000..498da3941c --- /dev/null +++ b/src/main/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtils.java @@ -0,0 +1,28 @@ +package com.commercetools.sync.customobjects.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; + +import javax.annotation.Nonnull; + +public class CustomObjectSyncUtils { + + /** + * Compares the value of a {@link CustomObject} to the value of a {@link CustomObjectDraft}. + * It returns a boolean whether the values are identical or not. + * + * @param oldCustomObject the {@link CustomObject} which should be synced. + * @param newCustomObject the {@link CustomObjectDraft} with the new data. + * @return A boolean whether the value of the CustomObject and CustomObjectDraft is identical or not. + */ + + public static boolean hasIdenticalValue( + @Nonnull final CustomObject oldCustomObject, + @Nonnull final CustomObjectDraft newCustomObject) { + JsonNode oldValue = oldCustomObject.getValue(); + JsonNode newValue = newCustomObject.getValue(); + + return oldValue.equals(newValue); + } +} \ No newline at end of file diff --git a/src/main/java/com/commercetools/sync/services/CustomObjectService.java b/src/main/java/com/commercetools/sync/services/CustomObjectService.java index 07fb83a0cc..88ed832aef 100644 --- a/src/main/java/com/commercetools/sync/services/CustomObjectService.java +++ b/src/main/java/com/commercetools/sync/services/CustomObjectService.java @@ -23,11 +23,11 @@ public interface CustomObjectService { * a {@link CompletionStage}<{@link Optional}<{@link String}>> * in which the {@link Optional} could contain the id inside of it. * - * @param identifier the identifier object containing CustomObject key and container, by which a - * {@link io.sphere.sdk.customobjects.CustomObject} id should be fetched from the CTP project. + * @param identifier the identifier object containing CustomObject key and container, by which a + * {@link io.sphere.sdk.customobjects.CustomObject} id should be fetched from the CTP project. * @return {@link CompletionStage}<{@link Optional}<{@link String}>> in which the result of its - * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no - * {@link CustomObject} was found in the CTP project with this identifier. + * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no + * {@link CustomObject} was found in the CTP project with this identifier. */ @Nonnull @@ -42,7 +42,7 @@ public interface CustomObjectService { * @param identifiers set of CustomObjectCompositeIdentifiers. Each identifier includes key and container to fetch * matching CustomObject. * @return {@link CompletionStage}<{@link Map}> in which the result of its completion contains a {@link Set} - * of all matching CustomObjects. + * of all matching CustomObjects. */ @Nonnull CompletionStage>> fetchMatchingCustomObjects( @@ -56,7 +56,7 @@ CompletionStage>> fetchMatchingCustomObjects( * * @param identifier the identifier of the CustomObject to fetch. * @return {@link CompletionStage}<{@link Optional}> in which the result of its completion contains an - * {@link Optional} that contains the matching {@link CustomObject} if exists, otherwise empty. + * {@link Optional} that contains the matching {@link CustomObject} if exists, otherwise empty. */ @Nonnull CompletionStage>> fetchCustomObject( @@ -82,7 +82,7 @@ CompletionStage>> fetchCustomObject( * * @param customObjectDraft the resource draft to create or update a resource based off of. * @return a {@link CompletionStage} containing an optional with the created/updated resource if successful - * otherwise an empty optional. + * otherwise an empty optional. */ @Nonnull CompletionStage>> upsertCustomObject( 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 808851563d..3358c607f1 100644 --- a/src/main/java/com/commercetools/sync/services/impl/BaseService.java +++ b/src/main/java/com/commercetools/sync/services/impl/BaseService.java @@ -43,7 +43,7 @@ * @param Expansion Model (e.g. {@link io.sphere.sdk.products.expansion.ProductExpansionModel}, * {@link io.sphere.sdk.categories.expansion.CategoryExpansionModel}, etc.. */ -abstract class BaseService, S extends BaseSyncOptions, +abstract class BaseService, S extends BaseSyncOptions, Q extends MetaModelQueryDsl, M, E> { final S syncOptions; @@ -67,7 +67,7 @@ abstract class BaseService, S extends BaseSyncOpt * resource. * @param updateActions the update actions to execute on the resource. * @return an instance of {@link CompletionStage}<{@code U}> which contains as a result an instance of - * the resource {@link U} after all the update actions have been executed. + * the resource {@link U} after all the update actions have been executed. */ @Nonnull CompletionStage updateResource( @@ -91,7 +91,7 @@ CompletionStage updateResource( * resource. * @param batches the batches of update actions to execute. * @return an instance of {@link CompletionStage}<{@code U}> which contains as a result an instance of - * the resource {@link U} after all the update actions in all batches have been executed. + * the resource {@link U} after all the update actions in all batches have been executed. */ @Nonnull private CompletionStage updateBatches( @@ -125,7 +125,7 @@ private CompletionStage updateBatches( * @param keyMapper a function to get the key from the supplied draft. * @param createCommand a function to get the create command using the supplied draft. * @return a {@link CompletionStage} containing an optional with the created resource if successful otherwise an - * empty optional. + * empty optional. */ @SuppressWarnings("unchecked") @Nonnull @@ -142,22 +142,8 @@ CompletionStage> createResource( null, draft, null); return CompletableFuture.completedFuture(Optional.empty()); } else { - return syncOptions - .getCtpClient() - .execute(createCommand.apply(draft)) - .handle(((resource, exception) -> { - if (exception == null) { - keyToIdCache.put(draftKey, resource.getId()); - return Optional.of(resource); - } else { - syncOptions.applyErrorCallback( - new SyncException(format(CREATE_FAILED, draftKey, exception.getMessage()), exception), - null, draft, null); - return Optional.empty(); - } - })); + return executeCreateCommand(draft, keyMapper, createCommand); } - } /** @@ -173,8 +159,8 @@ CompletionStage> createResource( * @param keyMapper a function to get the key from the resource. * @param querySupplier supplies the query to fetch the resource with the given key. * @return {@link CompletionStage}<{@link Optional}<{@link String}>> in which the result of it's - * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no - * resource was found in the CTP project with this key. + * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no + * resource was found in the CTP project with this key. */ @Nonnull CompletionStage> fetchCachedResourceId( @@ -247,7 +233,7 @@ CompletionStage> cacheKeysToIds( * @param keyMapper a function to get the key from the resource. * @param querySupplier supplies the query to fetch the resources with the given keys. * @return {@link CompletionStage}<{@link Set}<{@code U}>> in which the result of it's completion - * contains a {@link Set} of all matching resources. + * contains a {@link Set} of all matching resources. */ @Nonnull CompletionStage> fetchMatchingResources( @@ -277,7 +263,7 @@ CompletionStage> fetchMatchingResources( * @param key the key of the resource to fetch * @param querySupplier supplies the query to fetch the resource with the given key. * @return {@link CompletionStage}<{@link Optional}> in which the result of it's completion contains an - * {@link Optional} that contains the matching {@code T} if exists, otherwise empty. + * {@link Optional} that contains the matching {@code T} if exists, otherwise empty. */ @Nonnull CompletionStage> fetchResource( @@ -299,4 +285,27 @@ CompletionStage> fetchResource( })); } + @Nonnull + CompletionStage> executeCreateCommand( + @Nonnull final T draft, + @Nonnull final Function keyMapper, + @Nonnull final Function> createCommand) { + + final String draftKey = keyMapper.apply(draft); + + return syncOptions + .getCtpClient() + .execute(createCommand.apply(draft)) + .handle(((resource, exception) -> { + if (exception == null) { + keyToIdCache.put(draftKey, resource.getId()); + return Optional.of(resource); + } else { + syncOptions.applyErrorCallback( + new SyncException(format(CREATE_FAILED, draftKey, exception.getMessage()), exception), + null, draft, null); + return Optional.empty(); + } + })); + } } diff --git a/src/main/java/com/commercetools/sync/services/impl/BaseServiceWithKey.java b/src/main/java/com/commercetools/sync/services/impl/BaseServiceWithKey.java index 6b84b7c99f..47427a5cc7 100644 --- a/src/main/java/com/commercetools/sync/services/impl/BaseServiceWithKey.java +++ b/src/main/java/com/commercetools/sync/services/impl/BaseServiceWithKey.java @@ -29,7 +29,7 @@ * {@link io.sphere.sdk.categories.expansion.CategoryExpansionModel}, etc.. */ abstract class BaseServiceWithKey & WithKey, S extends BaseSyncOptions, - Q extends MetaModelQueryDsl, M, E> extends BaseService { + Q extends MetaModelQueryDsl, M, E> extends BaseService { BaseServiceWithKey(@Nonnull final S syncOptions) { super(syncOptions); @@ -52,7 +52,7 @@ abstract class BaseServiceWithKey & Wit * @param draft the resource draft to create a resource based off of. * @param createCommand a function to get the create command using the supplied draft. * @return a {@link CompletionStage} containing an optional with the created resource if successful otherwise an - * empty optional. + * empty optional. */ @Nonnull CompletionStage> createResource( @@ -71,11 +71,11 @@ CompletionStage> createResource( * could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no resource * was found in the CTP project with this key. * - * @param key the key by which a resource id should be fetched from the CTP project. + * @param key the key by which a resource id should be fetched from the CTP project. * @param querySupplier supplies the query to fetch the resource with the given key. * @return {@link CompletionStage}<{@link Optional}<{@link String}>> in which the result of it's - * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no - * resource was found in the CTP project with this key. + * completion could contain an {@link Optional} with the id inside of it or an empty {@link Optional} if no + * resource was found in the CTP project with this key. */ @Nonnull CompletionStage> fetchCachedResourceId( @@ -92,7 +92,7 @@ CompletionStage> fetchCachedResourceId( * Given a set of keys this method caches a mapping of the keys to ids of such keys only for the keys which are * not already in the cache. * - * @param keys keys to cache. + * @param keys keys to cache. * @param keysQueryMapper function that accepts a set of keys which are not cached and maps it to a query object * representing the query to CTP on such keys. * @return a map of key to ids of the requested keys. @@ -112,10 +112,10 @@ CompletionStage> cacheKeysToIds( * keys in the CTP project, defined in an injected {@link SphereClient}. A mapping of the key to the id * of the fetched resources is persisted in an in-memory map. * - * @param keys set of state keys to fetch matching states by + * @param keys set of state keys to fetch matching states by * @param querySupplier supplies the query to fetch the resources with the given keys. * @return {@link CompletionStage}<{@link Set}<{@code U}>> in which the result of it's completion - * contains a {@link Set} of all matching resources. + * contains a {@link Set} of all matching resources. */ @Nonnull CompletionStage> fetchMatchingResources( diff --git a/src/main/java/com/commercetools/sync/services/impl/CustomObjectServiceImpl.java b/src/main/java/com/commercetools/sync/services/impl/CustomObjectServiceImpl.java index e91b0aa5db..7c6410ee3e 100644 --- a/src/main/java/com/commercetools/sync/services/impl/CustomObjectServiceImpl.java +++ b/src/main/java/com/commercetools/sync/services/impl/CustomObjectServiceImpl.java @@ -1,9 +1,11 @@ package com.commercetools.sync.services.impl; +import com.commercetools.sync.customobjects.CustomObjectSync; import com.commercetools.sync.customobjects.CustomObjectSyncOptions; import com.commercetools.sync.customobjects.helpers.CustomObjectCompositeIdentifier; import com.commercetools.sync.services.CustomObjectService; import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.commands.DraftBasedCreateCommand; import io.sphere.sdk.customobjects.CustomObject; import io.sphere.sdk.customobjects.CustomObjectDraft; import io.sphere.sdk.customobjects.commands.CustomObjectUpsertCommand; @@ -20,6 +22,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -32,7 +35,7 @@ public class CustomObjectServiceImpl CustomObjectSyncOptions, CustomObjectQuery, CustomObjectQueryModel>, CustomObjectExpansionModel>> - implements CustomObjectService { + implements CustomObjectService { public CustomObjectServiceImpl(@Nonnull final CustomObjectSyncOptions syncOptions) { super(syncOptions); @@ -41,7 +44,7 @@ public CustomObjectServiceImpl(@Nonnull final CustomObjectSyncOptions syncOption @Nonnull @Override public CompletionStage> fetchCachedCustomObjectId( - @Nonnull final CustomObjectCompositeIdentifier identifier) { + @Nonnull final CustomObjectCompositeIdentifier identifier) { String container = identifier.getContainer(); String key = identifier.getKey(); @@ -53,19 +56,19 @@ public CompletionStage> fetchCachedCustomObjectId( return fetchCachedResourceId(identifier.toString(), draft -> CustomObjectCompositeIdentifier.of(draft).toString(), () -> CustomObjectQuery.ofJsonNode() - .withPredicates(q -> q.container().is(container).and(q.key().is(key))) + .withPredicates(q -> q.container().is(container).and(q.key().is(key))) ); } @Nonnull @Override public CompletionStage>> fetchMatchingCustomObjects( - @Nonnull final Set identifiers) { + @Nonnull final Set identifiers) { Set filteredIdentifiers = identifiers.stream() .filter(identifier -> - StringUtils.isNotEmpty(identifier.getContainer()) - && StringUtils.isNotEmpty(identifier.getKey())) + StringUtils.isNotEmpty(identifier.getContainer()) + && StringUtils.isNotEmpty(identifier.getKey())) .collect(Collectors.toSet()); if (filteredIdentifiers.size() == 0) { @@ -73,9 +76,9 @@ public CompletionStage>> fetchMatchingCustomObjects( } Set identifierStrings = - filteredIdentifiers.stream() - .map(CustomObjectCompositeIdentifier::toString) - .collect(Collectors.toSet()); + filteredIdentifiers.stream() + .map(CustomObjectCompositeIdentifier::toString) + .collect(Collectors.toSet()); return fetchMatchingResources(identifierStrings, draft -> CustomObjectCompositeIdentifier.of(draft).toString(), @@ -88,8 +91,8 @@ public CompletionStage>> fetchMatchingCustomObjects( @Nonnull private QueryPredicate> createQuery( - @Nonnull final CustomObjectQueryModel> queryModel, - @Nonnull final Set identifiers) { + @Nonnull final CustomObjectQueryModel> queryModel, + @Nonnull final Set identifiers) { QueryPredicate> queryPredicate = QueryPredicate.of(null); boolean firstAttempt = true; @@ -109,7 +112,7 @@ private QueryPredicate> createQuery( @Nonnull @Override public CompletionStage>> fetchCustomObject( - @Nonnull final CustomObjectCompositeIdentifier identifier) { + @Nonnull final CustomObjectCompositeIdentifier identifier) { String container = identifier.getContainer(); String key = identifier.getKey(); @@ -131,8 +134,42 @@ public CompletionStage>> upsertCustomObject( if (StringUtils.isEmpty(customObjectDraft.getKey()) || StringUtils.isEmpty(customObjectDraft.getContainer())) { return CompletableFuture.completedFuture(Optional.empty()); } - return createResource(customObjectDraft, - draft -> CustomObjectCompositeIdentifier.of(draft).toString(), - CustomObjectUpsertCommand::of); + CompletionStage>> createdResource = createResource(customObjectDraft, + draft -> CustomObjectCompositeIdentifier.of(draft).toString(), CustomObjectUpsertCommand::of); + return createdResource ; + } + + /** + * Custom object has special behaviour that it only performs upsert operation. That means both update and create + * custom object operations in the end called {@link BaseService#createResource}, which is different from other + * resources. + * + *

This method provides a specific exception handling after execution of create command for custom objects. + * Any exception that occurs inside executeCreateCommand method is thrown to the caller method in + * {@link CustomObjectSync}, which is necessary to trigger retry on error behaviour. + * + * @param draft the custom object draft to create a custom object in target CTP project. + * @param keyMapper a function to get the key from the supplied custom object draft. + * @param createCommand a function to get the create command using the supplied custom object draft. + * @return a {@link CompletionStage} containing an optional with the created resource if successful otherwise an + * exception. + */ + @Nonnull + @Override + CompletionStage>> executeCreateCommand( + @Nonnull final CustomObjectDraft draft, + @Nonnull final Function, String> keyMapper, + @Nonnull final Function, + DraftBasedCreateCommand, CustomObjectDraft>> createCommand) { + + final String draftKey = keyMapper.apply(draft); + + return syncOptions + .getCtpClient() + .execute(createCommand.apply(draft)) + .thenApply(resource -> { + keyToIdCache.put(draftKey, resource.getId()); + return Optional.of(resource); + }); } } 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 5365cdf359..e7b8e7d6de 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.customobjects.helpers.CustomObjectSyncStatistics; import com.commercetools.sync.inventories.helpers.InventorySyncStatistics; import com.commercetools.sync.products.helpers.ProductSyncStatistics; import com.commercetools.sync.producttypes.helpers.ProductTypeSyncStatistics; @@ -103,4 +104,15 @@ public static StateSyncStatisticsAssert assertThat(@Nullable final StateSyncStat public static TaxCategorySyncStatisticsAssert assertThat(@Nullable final TaxCategorySyncStatistics statistics) { return new TaxCategorySyncStatisticsAssert(statistics); } + + /** + * Create assertion for {@link CustomObjectSyncStatistics}. + * + * @param statistics the actual value. + * @return the created assertion object. + */ + @Nonnull + public static CustomObjectSyncStatisticsAssert assertThat(@Nullable final CustomObjectSyncStatistics statistics) { + return new CustomObjectSyncStatisticsAssert(statistics); + } } diff --git a/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomObjectSyncStatisticsAssert.java b/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomObjectSyncStatisticsAssert.java new file mode 100644 index 0000000000..63f43198d3 --- /dev/null +++ b/src/test/java/com/commercetools/sync/commons/asserts/statistics/CustomObjectSyncStatisticsAssert.java @@ -0,0 +1,12 @@ +package com.commercetools.sync.commons.asserts.statistics; + +import com.commercetools.sync.customobjects.helpers.CustomObjectSyncStatistics; +import javax.annotation.Nullable; + +public final class CustomObjectSyncStatisticsAssert extends + AbstractSyncStatisticsAssert { + + CustomObjectSyncStatisticsAssert(@Nullable final CustomObjectSyncStatistics actual) { + super(actual, CustomObjectSyncStatisticsAssert.class); + } +} diff --git a/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsBuilderTest.java b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsBuilderTest.java new file mode 100644 index 0000000000..915e9cad65 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsBuilderTest.java @@ -0,0 +1,189 @@ +package com.commercetools.sync.customobjects; + +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 com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomObjectSyncOptionsBuilderTest { + + private static final SphereClient CTP_CLIENT = mock(SphereClient.class); + private CustomObjectSyncOptionsBuilder customObjectSyncOptionsBuilder = + CustomObjectSyncOptionsBuilder.of(CTP_CLIENT); + + + @Test + void of_WithClient_ShouldCreateCustomObjectSyncOptionsBuilder() { + final CustomObjectSyncOptionsBuilder builder = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT); + assertThat(builder).isNotNull(); + } + + @Test + void build_WithClient_ShouldBuildSyncOptions() { + final CustomObjectSyncOptions customObjectSyncOptions = customObjectSyncOptionsBuilder.build(); + assertThat(customObjectSyncOptions).isNotNull(); + assertThat(customObjectSyncOptions.getBeforeUpdateCallback()).isNull(); + assertThat(customObjectSyncOptions.getBeforeCreateCallback()).isNull(); + assertThat(customObjectSyncOptions.getErrorCallback()).isNull(); + assertThat(customObjectSyncOptions.getWarningCallback()).isNull(); + assertThat(customObjectSyncOptions.getCtpClient()).isEqualTo(CTP_CLIENT); + assertThat(customObjectSyncOptions.getBatchSize()).isEqualTo(CustomObjectSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + } + + @Test + void beforeCreateCallback_WithFilterAsCallback_ShouldSetCallback() { + customObjectSyncOptionsBuilder.beforeCreateCallback((newCustomObject) -> null); + final CustomObjectSyncOptions customObjectSyncOptions = customObjectSyncOptionsBuilder.build(); + assertThat(customObjectSyncOptions.getBeforeCreateCallback()).isNotNull(); + } + + @Test + void errorCallBack_WithCallBack_ShouldSetCallBack() { + final QuadConsumer>, + Optional>, List>>> + mockErrorCallBack = (exception, newResource, oldResource, updateActions) -> { }; + customObjectSyncOptionsBuilder.errorCallback(mockErrorCallBack); + + final CustomObjectSyncOptions customObjectSyncOptions = customObjectSyncOptionsBuilder.build(); + assertThat(customObjectSyncOptions.getErrorCallback()).isNotNull(); + } + + @Test + void warningCallBack_WithCallBack_ShouldSetCallBack() { + final TriConsumer>, Optional>> mockWarningCallBack = + (exception, newResource, oldResource) -> { }; + customObjectSyncOptionsBuilder.warningCallback(mockWarningCallBack); + final CustomObjectSyncOptions cutomObjectSyncOptions = customObjectSyncOptionsBuilder.build(); + assertThat(cutomObjectSyncOptions.getWarningCallback()).isNotNull(); + } + + @Test + void getThis_ShouldReturnCorrectInstance() { + final CustomObjectSyncOptionsBuilder instance = customObjectSyncOptionsBuilder.getThis(); + assertThat(instance).isNotNull(); + assertThat(instance).isInstanceOf(CustomObjectSyncOptionsBuilder.class); + assertThat(instance).isEqualTo(customObjectSyncOptionsBuilder); + } + + @Test + void customObjectSyncOptionsBuilderSetters_ShouldBeCallableAfterBaseSyncOptionsBuildSetters() { + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(30) + .beforeCreateCallback((newCustomObject) -> null) + .beforeUpdateCallback((updateActions, newCustomObject, oldCustomObject) -> emptyList()) + .build(); + assertThat(customObjectSyncOptions).isNotNull(); + } + + @Test + void batchSize_WithPositiveValue_ShouldSetBatchSize() { + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .batchSize(10) + .build(); + assertThat(customObjectSyncOptions.getBatchSize()).isEqualTo(10); + } + + @Test + void batchSize_WithZeroOrNegativeValue_ShouldFallBackToDefaultValue() { + final CustomObjectSyncOptions customObjectSyncOptionsWithZeroBatchSize = + CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .batchSize(0) + .build(); + assertThat(customObjectSyncOptionsWithZeroBatchSize.getBatchSize()) + .isEqualTo(CustomObjectSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + final CustomObjectSyncOptions customObjectSyncOptionsWithNegativeBatchSize = CustomObjectSyncOptionsBuilder + .of(CTP_CLIENT) + .batchSize(-100) + .build(); + assertThat(customObjectSyncOptionsWithNegativeBatchSize.getBatchSize()) + .isEqualTo(CustomObjectSyncOptionsBuilder.BATCH_SIZE_DEFAULT); + } + + @Test + void applyBeforeUpdateCallBack_WithNullReturnCallbackAndEmptyUpdateActions_ShouldReturnEmptyList() { + final TriFunction>>, CustomObjectDraft, + CustomObject, List>>> + beforeUpdateCallback = (updateActions, newCustomObject, oldCustomObject) -> null; + + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeUpdateCallback( + beforeUpdateCallback) + .build(); + assertThat(customObjectSyncOptions.getBeforeUpdateCallback()).isNotNull(); + + final List>> updateActions = Collections.emptyList(); + + final List>> filteredList = + customObjectSyncOptions.applyBeforeUpdateCallback( + updateActions, mock(CustomObjectDraft.class), mock(CustomObject.class)); + assertThat(filteredList).isEqualTo(updateActions); + assertThat(filteredList).isEmpty(); + } + + @Test + void applyBeforeCreateCallBack_WithCallback_ShouldReturnFilteredDraft() { + final Function, CustomObjectDraft> draftFunction = + customObjectDraft -> CustomObjectDraft.ofUnversionedUpsert( + customObjectDraft.getContainer() + "_filteredContainer", + customObjectDraft.getKey() + "_filteredKey", + (JsonNode) customObjectDraft.getValue()); + + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback( + draftFunction) + .build(); + assertThat(customObjectSyncOptions.getBeforeCreateCallback()).isNotNull(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + when(resourceDraft.getKey()).thenReturn("myKey"); + when(resourceDraft.getContainer()).thenReturn("myContainer"); + final Optional> filteredDraft = + customObjectSyncOptions.applyBeforeCreateCallback(resourceDraft); + assertThat(filteredDraft).isNotEmpty(); + assertThat(filteredDraft.get().getKey()).isEqualTo("myKey_filteredKey"); + assertThat(filteredDraft.get().getContainer()).isEqualTo("myContainer_filteredContainer"); + } + + @Test + void applyBeforeCreateCallBack_WithNullCallback_ShouldReturnIdenticalDraftInOptional() { + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT).build(); + assertThat(customObjectSyncOptions.getBeforeCreateCallback()).isNull(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + final Optional> filteredDraft = + customObjectSyncOptions.applyBeforeCreateCallback(resourceDraft); + assertThat(filteredDraft).containsSame(resourceDraft); + } + + @Test + void applyBeforeCreateCallBack_WithCallbackReturningNull_ShouldReturnEmptyOptional() { + final Function, CustomObjectDraft> draftFunction = + customObjectDraft -> null; + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback( + draftFunction) + .build(); + assertThat(customObjectSyncOptions.getBeforeCreateCallback()).isNotNull(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + final Optional> filteredDraft = + customObjectSyncOptions.applyBeforeCreateCallback(resourceDraft); + assertThat(filteredDraft).isEmpty(); + } + +} diff --git a/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsTest.java b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsTest.java new file mode 100644 index 0000000000..4626004ca4 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncOptionsTest.java @@ -0,0 +1,142 @@ +package com.commercetools.sync.customobjects; + +import com.commercetools.sync.commons.utils.TriFunction; + +import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.commands.UpdateAction; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; +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 org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +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 CustomObjectSyncOptionsTest { + + private static SphereClient CTP_CLIENT = mock(SphereClient.class); + + private interface MockTriFunction extends + TriFunction>>, + CustomObjectDraft, CustomObject, List>>> { + } + + @Test + void applyBeforeUpdateCallback_WithNullCallbackAndEmptyUpdateActions_ShouldReturnIdenticalList() { + final CustomObjectSyncOptions customObjectSyncOptions = + CustomObjectSyncOptionsBuilder.of(CTP_CLIENT).build(); + + final List>> updateActions = emptyList(); + + final List>> filteredList = + customObjectSyncOptions.applyBeforeUpdateCallback(updateActions, + mock(CustomObjectDraft.class), mock(CustomObject.class)); + + assertThat(filteredList).isSameAs(updateActions); + } + + + @Test + void applyBeforeUpdateCallback_WithNullReturnCallbackAndEmptyUpdateActions_ShouldReturnEmptyList() { + final TriFunction>>, CustomObjectDraft, + CustomObject, List>>> + beforeUpdateCallback = (updateActions, newCustomObject, oldCustomObject) -> null; + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeUpdateCallback( + beforeUpdateCallback) + .build(); + final List>> updateActions = emptyList(); + + final List>> filteredList = + customObjectSyncOptions.applyBeforeUpdateCallback( + updateActions, mock(CustomObjectDraft.class), mock(CustomObject.class)); + + assertAll( + () -> assertThat(filteredList).isEqualTo(updateActions), + () -> assertThat(filteredList).isEmpty() + ); + } + + + @Test + void applyBeforeUpdateCallback_WithEmptyUpdateActions_ShouldNotApplyBeforeUpdateCallback() { + final CustomObjectSyncOptionsTest.MockTriFunction beforeUpdateCallback = + mock(CustomObjectSyncOptionsTest.MockTriFunction.class); + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeUpdateCallback( + beforeUpdateCallback) + .build(); + + final List>> filteredList = + customObjectSyncOptions.applyBeforeUpdateCallback(emptyList(), + mock(CustomObjectDraft.class), mock(CustomObject.class)); + + assertThat(filteredList).isEmpty(); + verify(beforeUpdateCallback, never()).apply(any(), any(), any()); + } + + + @Test + void applyBeforeCreateCallback_WithCallback_ShouldReturnFilteredDraft() { + + final Function, CustomObjectDraft> draftFunction = + customObjectDraft -> CustomObjectDraft.ofUnversionedUpsert( + customObjectDraft.getContainer() + "_filteredContainer", + customObjectDraft.getKey() + "_filteredKey", customObjectDraft.getValue()); + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback( + draftFunction) + .build(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + when(resourceDraft.getKey()).thenReturn("myKey"); + when(resourceDraft.getContainer()).thenReturn("myContainer"); + + final Optional> filteredDraft = customObjectSyncOptions + .applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).hasValueSatisfying(customObjectDraft -> + assertAll( + () -> assertThat(customObjectDraft.getKey()).isEqualTo("myKey_filteredKey"), + () -> assertThat(customObjectDraft.getContainer()).isEqualTo("myContainer_filteredContainer") + )); + } + + @Test + void applyBeforeCreateCallback_WithNullCallback_ShouldReturnIdenticalDraftInOptional() { + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT).build(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + + final Optional> filteredDraft = customObjectSyncOptions + .applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).containsSame(resourceDraft); + } + + @Test + void applyBeforeCreateCallback_WithCallbackReturningNull_ShouldReturnEmptyOptional() { + final Function, CustomObjectDraft> draftFunction = + customObjectDraft -> null; + final CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(CTP_CLIENT) + .beforeCreateCallback( + draftFunction) + .build(); + final CustomObjectDraft resourceDraft = mock(CustomObjectDraft.class); + + final Optional> filteredDraft = customObjectSyncOptions + .applyBeforeCreateCallback(resourceDraft); + + assertThat(filteredDraft).isEmpty(); + } +} diff --git a/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncTest.java b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncTest.java new file mode 100644 index 0000000000..63a23f488a --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/CustomObjectSyncTest.java @@ -0,0 +1,495 @@ +package com.commercetools.sync.customobjects; + +import com.commercetools.sync.customobjects.helpers.CustomObjectCompositeIdentifier; +import com.commercetools.sync.customobjects.helpers.CustomObjectSyncStatistics; +import com.commercetools.sync.services.CustomObjectService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import io.sphere.sdk.client.ConcurrentModificationException; +import io.sphere.sdk.client.SphereClient; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; +import io.sphere.sdk.models.SphereException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletionException; + +import static com.commercetools.sync.commons.asserts.statistics.AssertionsForStatistics.assertThat; +import static java.lang.String.format; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.CompletableFuture.supplyAsync; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +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 CustomObjectSyncTest { + + private CustomObjectDraft newCustomObjectDraft; + + @BeforeEach + void setup() { + newCustomObjectDraft = CustomObjectDraft + .ofUnversionedUpsert("someContainer", "someKey", + JsonNodeFactory.instance.objectNode().put("json-field", "json-value")); + } + + @Test + void sync_WithErrorFetchingExistingKeys_ShouldExecuteCallbackOnErrorAndIncreaseFailedCounter() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObjectService mockCustomObjectService = mock(CustomObjectService.class); + + when(mockCustomObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(supplyAsync(() -> { + throw new SphereException(); + })); + + final CustomObjectSync customObjectSync = + new CustomObjectSync(spyCustomObjectSyncOptions, mockCustomObjectService); + + // test + final CustomObjectSyncStatistics customObjectSyncStatistics = customObjectSync + .sync(singletonList(newCustomObjectDraft)) + .toCompletableFuture().join(); + + // assertion + assertThat(errorMessages) + .hasSize(1).singleElement().asString() + .isEqualTo("Failed to fetch existing custom objects with keys: " + + "'[{key='someKey', container='someContainer'}]'."); + + assertThat(exceptions) + .hasSize(1).singleElement().isInstanceOfSatisfying(Throwable.class, throwable -> { + assertThat(throwable).isExactlyInstanceOf(CompletionException.class); + assertThat(throwable).hasCauseExactlyInstanceOf(SphereException.class); + }); + + assertThat(customObjectSyncStatistics).hasValues(1, 0, 0, 1); + } + + @Test + void sync_WithOnlyDraftsToCreate_ShouldCallBeforeCreateCallback_ShouldNotCallBeforeUpdateCallback() { + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(emptyList(),emptyList()); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())).thenReturn(completedFuture(emptySet())); + when(customObjectService.upsertCustomObject(any())).thenReturn(completedFuture(Optional.empty())); + + // test + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + verify(spyCustomObjectSyncOptions).applyBeforeCreateCallback(newCustomObjectDraft); + verify(spyCustomObjectSyncOptions, never()).applyBeforeUpdateCallback(any(), any(), any()); + } + + @Test + void sync_WithOnlyDraftsToUpdate_ShouldCallBeforeCreateCallback_ShouldNotCallBeforeUpdateCallback() { + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(emptyList(), emptyList()); + + final CustomObject mockedExistingCustomObject = mock(CustomObject.class); + when(mockedExistingCustomObject.getKey()).thenReturn(newCustomObjectDraft.getKey()); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(singleton(mockedExistingCustomObject))); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(mockedExistingCustomObject))); + + // test + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + verify(spyCustomObjectSyncOptions).applyBeforeCreateCallback(newCustomObjectDraft); + verify(spyCustomObjectSyncOptions, never()).applyBeforeUpdateCallback(any(), any(), any()); + } + + @Test + void sync_WithSameIdentifiersAndDifferentValues_ShouldUpdateSuccessfully() { + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(emptyList(), emptyList()); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("someContainer"); + when(existingCustomObject.getKey()).thenReturn("someKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(updatedCustomObject))); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(0) + ); + } + + @Test + void sync_WithSameIdentifiersAndIdenticalValues_ShouldProcessedAndNotUpdated() { + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(emptyList(), emptyList()); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("someContainer"); + when(existingCustomObject.getKey()).thenReturn("someKey"); + when(existingCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(updatedCustomObject))); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(0) + ); + } + + + @Test + void sync_UpdateWithConcurrentModificationExceptionAndRetryWithFetchException_ShouldIncrementFailed() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("someContainer"); + when(existingCustomObject.getKey()).thenReturn("someKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(supplyAsync(() -> { throw new ConcurrentModificationException(); })); + when(customObjectService.fetchCustomObject(any(CustomObjectCompositeIdentifier.class))) + .thenReturn(supplyAsync(() -> { + throw new SphereException(); + })); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + // assertion + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(1) + ); + assertThat(exceptions).hasSize(1); + assertThat(errorMessages) + .hasSize(1) + .singleElement() + .isEqualTo( + format("Failed to update custom object with key: '%s'. Reason: %s", + CustomObjectCompositeIdentifier.of(newCustomObjectDraft).toString(), + "Failed to fetch from CTP while retrying after concurrency modification.") + ); + + verify(customObjectService).fetchCustomObject(any(CustomObjectCompositeIdentifier.class)); + verify(customObjectService).upsertCustomObject(any()); + verify(customObjectService).fetchMatchingCustomObjects(any()); + } + + @Test + void sync_UpdateWithSphereExceptionAndRetryWithFetchException_ShouldIncrementFailed() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("someContainer"); + when(existingCustomObject.getKey()).thenReturn("someKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(supplyAsync(() -> { throw new SphereException(); })); + when(customObjectService.fetchCustomObject(any(CustomObjectCompositeIdentifier.class))) + .thenReturn(supplyAsync(() -> { + throw new SphereException(); + })); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(1) + ); + assertThat(exceptions).hasSize(1); + assertThat(errorMessages) + .hasSize(1) + .singleElement() + .isEqualTo( + format("Failed to update custom object with key: '%s'. Reason: %s", + CustomObjectCompositeIdentifier.of(newCustomObjectDraft).toString(), + exceptions.get(0).getMessage()) + ); + verify(customObjectService).upsertCustomObject(any()); + verify(customObjectService).fetchMatchingCustomObjects(any()); + } + + @Test + void sync_WithDifferentIdentifiers_ShouldCreateSuccessfully() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("otherContainer"); + when(existingCustomObject.getKey()).thenReturn("otherKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(updatedCustomObject))); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertThat(exceptions).hasSize(0); + assertThat(errorMessages).hasSize(0); + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(0) + ); + verify(spyCustomObjectSyncOptions).applyBeforeCreateCallback(newCustomObjectDraft); + } + + @Test + void sync_WithSameKeysAndDifferentContainers_ShouldCreateSuccessfully() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("otherContainer"); + when(existingCustomObject.getKey()).thenReturn("someKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(updatedCustomObject))); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertThat(exceptions).hasSize(0); + assertThat(errorMessages).hasSize(0); + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(0) + ); + verify(spyCustomObjectSyncOptions).applyBeforeCreateCallback(newCustomObjectDraft); + } + + @Test + void sync_WithDifferentKeysAndSameContainers_ShouldCreateSuccessfully() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObject existingCustomObject = mock(CustomObject.class); + when(existingCustomObject.getContainer()).thenReturn("someContainer"); + when(existingCustomObject.getKey()).thenReturn("otherKey"); + when(existingCustomObject.getValue()).thenReturn(JsonNodeFactory.instance.numberNode(2020)); + + final CustomObject updatedCustomObject = mock(CustomObject.class); + when(updatedCustomObject.getContainer()).thenReturn("someContainer"); + when(updatedCustomObject.getKey()).thenReturn("someKey"); + when(updatedCustomObject.getValue()).thenReturn(newCustomObjectDraft.getValue()); + + final Set> existingCustomObjectSet = new HashSet>(); + existingCustomObjectSet.add(existingCustomObject); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())) + .thenReturn(completedFuture(existingCustomObjectSet)); + when(customObjectService.upsertCustomObject(any())) + .thenReturn(completedFuture(Optional.of(updatedCustomObject))); + + // test + CustomObjectSyncStatistics syncStatistics = + new CustomObjectSync(spyCustomObjectSyncOptions, customObjectService) + .sync(singletonList(newCustomObjectDraft)).toCompletableFuture().join(); + + // assertion + assertThat(exceptions).hasSize(0); + assertThat(errorMessages).hasSize(0); + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(0) + ); + verify(spyCustomObjectSyncOptions).applyBeforeCreateCallback(newCustomObjectDraft); + } + + @Test + void sync_WitEmptyValidDrafts_ShouldFailed() { + final List errorMessages = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + + final CustomObjectSyncOptions spyCustomObjectSyncOptions = + initCustomObjectSyncOptions(errorMessages, exceptions); + + final CustomObjectService customObjectService = mock(CustomObjectService.class); + when(customObjectService.fetchMatchingCustomObjects(anySet())).thenReturn(completedFuture(emptySet())); + when(customObjectService.upsertCustomObject(any())).thenReturn(completedFuture(Optional.empty())); + + // test + CustomObjectSyncStatistics syncStatistics = new CustomObjectSync( + spyCustomObjectSyncOptions, customObjectService).sync( + singletonList(null)).toCompletableFuture().join(); + + // assertion + assertThat(exceptions).hasSize(1); + assertThat(errorMessages).hasSize(1); + assertAll( + () -> assertThat(syncStatistics.getProcessed().get()).isEqualTo(1), + () -> assertThat(syncStatistics.getCreated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getUpdated().get()).isEqualTo(0), + () -> assertThat(syncStatistics.getFailed().get()).isEqualTo(1) + ); + } + + @Nonnull + private CustomObjectSyncOptions initCustomObjectSyncOptions( + @Nonnull final List errorMessages, + @Nonnull final List exceptions) { + return spy(CustomObjectSyncOptionsBuilder + .of(mock(SphereClient.class)) + .errorCallback((exception, oldResource, newResource, updateActions) -> { + errorMessages.add(exception.getMessage()); + exceptions.add(exception.getCause()); + }) + .build()); + } +} diff --git a/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifierTest.java b/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifierTest.java new file mode 100644 index 0000000000..6c0884e1bb --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectCompositeIdentifierTest.java @@ -0,0 +1,183 @@ +package com.commercetools.sync.customobjects.helpers; + +import com.fasterxml.jackson.databind.JsonNode; +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CustomObjectCompositeIdentifierTest { + + private static final String CONTAINER = "container"; + private static final String KEY = "key"; + + @Test + void of_WithCustomObjectDraft_ShouldCreateCustomObjectCompositeIdentifier() { + final CustomObjectDraft customObjectDraft = + CustomObjectDraft.ofUnversionedUpsert(CONTAINER, KEY, null); + + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(customObjectDraft); + + assertThat(customObjectCompositeIdentifier).isNotNull(); + assertThat(customObjectCompositeIdentifier.getContainer()).isEqualTo(CONTAINER); + assertThat(customObjectCompositeIdentifier.getKey()).isEqualTo(KEY); + } + + @Test + void of_WithContainerAndKeyParams_ShouldCreateCustomObjectCompositeIdentifier() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + + assertThat(customObjectCompositeIdentifier).isNotNull(); + assertThat(customObjectCompositeIdentifier.getContainer()).isEqualTo(CONTAINER); + assertThat(customObjectCompositeIdentifier.getKey()).isEqualTo(KEY); + } + + @Test + void of_WithCustomObject_ShouldCreateCustomObjectCompositeIdentifier() { + final CustomObject customObject = mock(CustomObject.class); + when(customObject.getContainer()).thenReturn(CONTAINER); + when(customObject.getKey()).thenReturn(KEY); + + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(customObject); + + assertThat(customObjectCompositeIdentifier).isNotNull(); + assertThat(customObjectCompositeIdentifier.getContainer()).isEqualTo(CONTAINER); + assertThat(customObjectCompositeIdentifier.getKey()).isEqualTo(KEY); + } + + @Test + void equals_WithSameObj_ShouldReturnTrue() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + + boolean result = customObjectCompositeIdentifier.equals(customObjectCompositeIdentifier); + + assertTrue(result); + } + + @Test + void equals_WithDiffType_ShouldReturnFalse() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + final Object other = new Object(); + + boolean result = customObjectCompositeIdentifier.equals(other); + + assertFalse(result); + } + + @Test + void equals_WithEqualObjects_ShouldReturnTrue() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + + boolean result = customObjectCompositeIdentifier.equals(other); + + assertTrue(result); + } + + @Test + void equals_WithDifferentKeys_ShouldReturnFalse() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of("key1", CONTAINER); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of("key2", CONTAINER); + + boolean result = customObjectCompositeIdentifier.equals(other); + + assertFalse(result); + } + + @Test + void equals_WithDifferentContainers_ShouldReturnFalse() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, "container1"); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of(KEY, "container2"); + + boolean result = customObjectCompositeIdentifier.equals(other); + + assertFalse(result); + } + + @Test + void hashCode_withSameInstances_ShouldBeEquals() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + final CustomObjectCompositeIdentifier other = customObjectCompositeIdentifier; + + final int hash1 = customObjectCompositeIdentifier.hashCode(); + final int hash2 = other.hashCode(); + + assertEquals(hash1, hash2); + } + + @Test + void hashCode_withSameKeyAndSameContainer_ShouldBeEquals() { + // preparation + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of(KEY, CONTAINER); + + // test + final int hash1 = customObjectCompositeIdentifier.hashCode(); + final int hash2 = other.hashCode(); + + // assertions + assertEquals(hash1, hash2); + } + + @Test + void hashCode_withDifferentKeyAndSameContainer_ShouldNotBeEquals() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of("key1", CONTAINER); + + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of("key2", CONTAINER); + + final int hash1 = customObjectCompositeIdentifier.hashCode(); + final int hash2 = other.hashCode(); + + assertNotEquals(hash1, hash2); + } + + @Test + void hashCode_withSameKeyAndDifferentContainer_ShouldNotBeEquals() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of(KEY, "container1"); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of(KEY, "container2"); + + final int hash1 = customObjectCompositeIdentifier.hashCode(); + final int hash2 = other.hashCode(); + + assertNotEquals(hash1, hash2); + } + + @Test + void hashCode_withCompletelyDifferentValues_ShouldNotBeEquals() { + final CustomObjectCompositeIdentifier customObjectCompositeIdentifier + = CustomObjectCompositeIdentifier.of("key1", "container1"); + final CustomObjectCompositeIdentifier other + = CustomObjectCompositeIdentifier.of("key2", "container2"); + + final int hash1 = customObjectCompositeIdentifier.hashCode(); + final int hash2 = other.hashCode(); + + assertNotEquals(hash1, hash2); + } + +} \ No newline at end of file diff --git a/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatisticsTest.java b/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatisticsTest.java new file mode 100644 index 0000000000..71f7190418 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatisticsTest.java @@ -0,0 +1,28 @@ +package com.commercetools.sync.customobjects.helpers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +class CustomObjectSyncStatisticsTest { + private CustomObjectSyncStatistics customObjectSyncStatistics; + + @BeforeEach + void setup() { + customObjectSyncStatistics = new CustomObjectSyncStatistics(); + } + + @Test + void getReportMessage_WithIncrementedStats_ShouldGetCorrectMessage() { + customObjectSyncStatistics.incrementCreated(1); + customObjectSyncStatistics.incrementFailed(2); + customObjectSyncStatistics.incrementUpdated(3); + customObjectSyncStatistics.incrementProcessed(6); + + assertThat(customObjectSyncStatistics.getReportMessage()) + .isEqualTo("Summary: 6 custom objects were processed in total " + + "(1 created, 3 updated and 2 failed to sync)."); + } +} diff --git a/src/test/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtilsTest.java b/src/test/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtilsTest.java new file mode 100644 index 0000000000..97e34c4078 --- /dev/null +++ b/src/test/java/com/commercetools/sync/customobjects/utils/CustomObjectSyncUtilsTest.java @@ -0,0 +1,194 @@ +package com.commercetools.sync.customobjects.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import io.sphere.sdk.customobjects.CustomObject; +import io.sphere.sdk.customobjects.CustomObjectDraft; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomObjectSyncUtilsTest { + + private CustomObject oldCustomObject; + private CustomObjectDraft newCustomObjectdraft; + + private void prepareMockObjects(final JsonNode actualObj, final JsonNode mockedObj) { + final String key = "testkey"; + final String container = "testcontainer"; + + newCustomObjectdraft = CustomObjectDraft.ofUnversionedUpsert(container, key, actualObj); + oldCustomObject = mock(CustomObject.class); + when(oldCustomObject.getValue()).thenReturn(mockedObj); + when(oldCustomObject.getContainer()).thenReturn(container); + when(oldCustomObject.getKey()).thenReturn(key); + } + + @Test + void hasIdenticalValue_WithSameBooleanValue_ShouldBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("true"); + JsonNode mockedObj = new ObjectMapper().readTree("true"); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isBoolean()).isTrue(); + assertThat(mockedObj.isBoolean()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithDifferentBooleanValue_ShouldNotBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("true"); + JsonNode mockedObj = new ObjectMapper().readTree("false"); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isBoolean()).isTrue(); + assertThat(mockedObj.isBoolean()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isFalse(); + } + + @Test + void hasIdenticalValue_WithSameNumberValue_ShouldBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("2020"); + JsonNode mockedObj = new ObjectMapper().readTree("2020"); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isNumber()).isTrue(); + assertThat(mockedObj.isNumber()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithDifferentNumberValue_ShouldNotBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("2020"); + JsonNode mockedObj = new ObjectMapper().readTree("2021"); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isNumber()).isTrue(); + assertThat(mockedObj.isNumber()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isFalse(); + } + + @Test + void hasIdenticalValue_WithSameStringValue_ShouldBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("\"CommerceTools\""); + JsonNode mockedObj = new ObjectMapper().readTree("\"CommerceTools\""); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isTextual()).isTrue(); + assertThat(mockedObj.isTextual()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithDifferentStringValue_ShouldNotBeIdentical() throws JsonProcessingException { + JsonNode actualObj = new ObjectMapper().readTree("\"CommerceToolsPlatform\""); + JsonNode mockedObj = new ObjectMapper().readTree("\"CommerceTools\""); + prepareMockObjects(actualObj, mockedObj); + assertThat(actualObj.isTextual()).isTrue(); + assertThat(mockedObj.isTextual()).isTrue(); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isFalse(); + } + + @Test + void hasIdenticalValue_WithSameFieldAndValueInJsonNode_ShouldBeIdentical() { + + ObjectNode oldValue = JsonNodeFactory.instance.objectNode().put("username", "Peter"); + ObjectNode newValue = JsonNodeFactory.instance.objectNode().put("username", "Peter"); + prepareMockObjects(oldValue, newValue); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithSameFieldAndDifferentValueInJsonNode_ShouldNotBeIdentical() { + + ObjectNode oldValue = JsonNodeFactory.instance.objectNode().put("username", "Peter"); + ObjectNode newValue = JsonNodeFactory.instance.objectNode().put("username", "Joe"); + prepareMockObjects(oldValue, newValue); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isFalse(); + } + + @Test + void hasIdenticalValue_WithSameFieldAndValueInDifferentOrderInJsonNode_ShouldBeIdentical() { + + ObjectNode oldValue = JsonNodeFactory.instance.objectNode() + .put("username", "Peter") + .put("userId", "123-456-789"); + + ObjectNode newValue = JsonNodeFactory.instance.objectNode() + .put("userId", "123-456-789") + .put("username", "Peter"); + + prepareMockObjects(oldValue, newValue); + + assertThat(oldValue.toString()).isNotEqualTo(newValue.toString()); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithSameNestedJsonNode_WithSameAttributeOrderInNestedJson_ShouldBeIdentical() { + + JsonNode oldNestedJson = JsonNodeFactory.instance.objectNode() + .put("username", "Peter") + .put("userId", "123-456-789"); + JsonNode newNestedJson = JsonNodeFactory.instance.objectNode() + .put("username", "Peter") + .put("userId", "123-456-789"); + + JsonNode oldJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", oldNestedJson); + + JsonNode newJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", newNestedJson); + + prepareMockObjects(oldJsonNode, newJsonNode); + + assertThat(oldJsonNode.toString()).isEqualTo(newJsonNode.toString()); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithSameNestedJsonNode_WithDifferentAttributeOrderInNestedJson_ShouldBeIdentical() { + + JsonNode oldNestedJson = JsonNodeFactory.instance.objectNode() + .put("username", "Peter") + .put("userId", "123-456-789"); + JsonNode newNestedJson = JsonNodeFactory.instance.objectNode() + .put("userId", "123-456-789") + .put("username", "Peter"); + + JsonNode oldJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", oldNestedJson); + + JsonNode newJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", newNestedJson); + + prepareMockObjects(oldJsonNode, newJsonNode); + + assertThat(oldJsonNode.toString()).isNotEqualTo(newJsonNode.toString()); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isTrue(); + } + + @Test + void hasIdenticalValue_WithDifferentNestedJsonNode_ShouldNotBeIdentical() { + + JsonNode oldNestedJson = JsonNodeFactory.instance.objectNode() + .put("username", "Peter") + .put("userId", "123-456-789"); + JsonNode newNestedJson = JsonNodeFactory.instance.objectNode() + .put("userId", "129-382-189") + .put("username", "Peter"); + + JsonNode oldJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", oldNestedJson); + + JsonNode newJsonNode = JsonNodeFactory.instance.objectNode() + .set("nestedJson", newNestedJson); + + prepareMockObjects(oldJsonNode, newJsonNode); + + assertThat(oldJsonNode.toString()).isNotEqualTo(newJsonNode.toString()); + assertThat(CustomObjectSyncUtils.hasIdenticalValue(oldCustomObject, newCustomObjectdraft)).isFalse(); + } +} diff --git a/src/test/java/com/commercetools/sync/services/impl/BaseServiceImplTest.java b/src/test/java/com/commercetools/sync/services/impl/BaseServiceImplTest.java index 7f0f88e4b6..290cc4d8e3 100644 --- a/src/test/java/com/commercetools/sync/services/impl/BaseServiceImplTest.java +++ b/src/test/java/com/commercetools/sync/services/impl/BaseServiceImplTest.java @@ -45,7 +45,7 @@ class BaseServiceImplTest { @SuppressWarnings("unchecked") private TriConsumer, Optional> warningCallback - = mock(TriConsumer.class); + = mock(TriConsumer.class); private SphereClient client = mock(SphereClient.class); private ProductService service; diff --git a/src/test/java/com/commercetools/sync/services/impl/CustomObjectServiceImplTest.java b/src/test/java/com/commercetools/sync/services/impl/CustomObjectServiceImplTest.java index 64edbbea8d..b33a7fad0e 100644 --- a/src/test/java/com/commercetools/sync/services/impl/CustomObjectServiceImplTest.java +++ b/src/test/java/com/commercetools/sync/services/impl/CustomObjectServiceImplTest.java @@ -14,6 +14,7 @@ import io.sphere.sdk.customobjects.queries.CustomObjectQuery; import io.sphere.sdk.queries.PagedQueryResult; import io.sphere.sdk.utils.CompletableFutureUtils; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -22,6 +23,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; + import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -45,8 +47,6 @@ class CustomObjectServiceImplTest { private SphereClient client = mock(SphereClient.class); private CustomObjectServiceImpl service; - private List errorMessages; - private List errorExceptions; private String customObjectId; private String customObjectContainer; @@ -58,14 +58,7 @@ void setup() { customObjectContainer = RandomStringUtils.random(15, true, true); customObjectKey = RandomStringUtils.random(15, true, true); - errorMessages = new ArrayList<>(); - errorExceptions = new ArrayList<>(); - CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(client) - .errorCallback((exception, oldResource, newResource, updateActions) -> { - errorMessages.add(exception.getMessage()); - errorExceptions.add(exception.getCause()); - }) - .build(); + CustomObjectSyncOptions customObjectSyncOptions = CustomObjectSyncOptionsBuilder.of(client).build(); service = new CustomObjectServiceImpl(customObjectSyncOptions); } @@ -121,8 +114,8 @@ void fetchCachedCustomObjectId_WithEmptyKey_ShouldNotFetch() { final Optional fetchedId = service - .fetchCachedCustomObjectId(CustomObjectCompositeIdentifier.of(key, container)) - .toCompletableFuture().join(); + .fetchCachedCustomObjectId(CustomObjectCompositeIdentifier.of(key, container)) + .toCompletableFuture().join(); assertThat(fetchedId).isEmpty(); verify(client, times(0)).execute(any(CustomObjectQuery.class)); @@ -146,8 +139,8 @@ void fetchCachedCustomObjectId_WithEmptyCotainer_ShouldNotFetch() { final Optional fetchedId = service - .fetchCachedCustomObjectId(CustomObjectCompositeIdentifier.of(key, container)) - .toCompletableFuture().join(); + .fetchCachedCustomObjectId(CustomObjectCompositeIdentifier.of(key, container)) + .toCompletableFuture().join(); assertThat(fetchedId).isEmpty(); verify(client, times(0)).execute(any(CustomObjectQuery.class)); } @@ -195,11 +188,11 @@ void fetchMatchingCustomObjects_WithKeySet_ShouldFetchCustomObjects() { } @Test - void fetchMatchingCustomObjects_WithAllEmptyKeyAndContainer_ShouldNotFetch() { + void fetchMatchingCustomObjects_WithAllEmptyKey_ShouldNotFetch() { final String key1 = ""; final String key2 = ""; - final String container1 = ""; - final String container2 = ""; + final String container1 = RandomStringUtils.random(15, true, true); + final String container2 = RandomStringUtils.random(15, true, true); final Set customObjectCompositeIdentifiers = new HashSet<>(); customObjectCompositeIdentifiers.add(CustomObjectCompositeIdentifier.of(key1, container1)); @@ -220,28 +213,27 @@ void fetchMatchingCustomObjects_WithAllEmptyKeyAndContainer_ShouldNotFetch() { when(client.execute(any())).thenReturn(CompletableFuture.completedFuture(result)); - final Set> customObjects = service - .fetchMatchingCustomObjects(customObjectCompositeIdentifiers) - .toCompletableFuture().join(); + final Set> customObjects = + service.fetchMatchingCustomObjects(customObjectCompositeIdentifiers).toCompletableFuture().join(); List customObjectCompositeIdlist = - new ArrayList(customObjectCompositeIdentifiers); + new ArrayList(customObjectCompositeIdentifiers); assertAll( () -> assertThat(customObjects).isEmpty(), () -> assertThat(service.keyToIdCache).doesNotContainKeys( - String.valueOf(customObjectCompositeIdlist.get(0)), - String.valueOf(customObjectCompositeIdlist.get(1))) + String.valueOf(customObjectCompositeIdlist.get(0)), + String.valueOf(customObjectCompositeIdlist.get(1))) ); verify(client, times(0)).execute(any(CustomObjectQuery.class)); } @Test - void fetchMatchingCustomObjects_WithAllEmptyKey_ShouldNotFetch() { - final String key1 = ""; - final String key2 = ""; - final String container1 = RandomStringUtils.random(15, true, true); - final String container2 = RandomStringUtils.random(15, true, true); + void fetchMatchingCustomObjects_WithAllEmptyContainer_ShouldNotFetch() { + final String key1 = RandomStringUtils.random(15, true, true); + final String key2 = RandomStringUtils.random(15, true, true); + final String container1 = ""; + final String container2 = ""; final Set customObjectCompositeIdentifiers = new HashSet<>(); customObjectCompositeIdentifiers.add(CustomObjectCompositeIdentifier.of(key1, container1)); @@ -262,9 +254,8 @@ void fetchMatchingCustomObjects_WithAllEmptyKey_ShouldNotFetch() { when(client.execute(any())).thenReturn(CompletableFuture.completedFuture(result)); - final Set> customObjects = service - .fetchMatchingCustomObjects(customObjectCompositeIdentifiers) - .toCompletableFuture().join(); + final Set> customObjects = + service.fetchMatchingCustomObjects(customObjectCompositeIdentifiers).toCompletableFuture().join(); List customObjectCompositeIdlist = new ArrayList(customObjectCompositeIdentifiers); @@ -272,8 +263,8 @@ void fetchMatchingCustomObjects_WithAllEmptyKey_ShouldNotFetch() { assertAll( () -> assertThat(customObjects).isEmpty(), () -> assertThat(service.keyToIdCache).doesNotContainKeys( - String.valueOf(customObjectCompositeIdlist.get(0)), - String.valueOf(customObjectCompositeIdlist.get(1))) + String.valueOf(customObjectCompositeIdlist.get(0)), + String.valueOf(customObjectCompositeIdlist.get(1))) ); verify(client, times(0)).execute(any(CustomObjectQuery.class)); } @@ -317,15 +308,15 @@ void fetchCustomObject_WithEmptyKey_ShouldNotFetch() { final Optional> customObjectOptional = service - .fetchCustomObject(CustomObjectCompositeIdentifier.of("", customObjectContainer)) - .toCompletableFuture().join(); + .fetchCustomObject(CustomObjectCompositeIdentifier.of("", customObjectContainer)) + .toCompletableFuture().join(); assertAll( () -> assertThat(customObjectOptional).isEmpty(), () -> assertThat( - service.keyToIdCache.get( - CustomObjectCompositeIdentifier.of(customObjectKey, customObjectContainer).toString()) - ).isNotEqualTo(customObjectId) + service.keyToIdCache.get( + CustomObjectCompositeIdentifier.of(customObjectKey, customObjectContainer).toString()) + ).isNotEqualTo(customObjectId) ); verify(client, times(0)).execute(any(CustomObjectQuery.class)); } @@ -343,15 +334,15 @@ void fetchCustomObject_WithEmptyContainer_ShouldNotFetch() { final Optional> customObjectOptional = service - .fetchCustomObject(CustomObjectCompositeIdentifier.of(customObjectKey, "")) - .toCompletableFuture().join(); + .fetchCustomObject(CustomObjectCompositeIdentifier.of(customObjectKey, "")) + .toCompletableFuture().join(); assertAll( () -> assertThat(customObjectOptional).isEmpty(), () -> assertThat( - service.keyToIdCache.get( - CustomObjectCompositeIdentifier.of(customObjectKey, customObjectContainer).toString()) - ).isNotEqualTo(customObjectId) + service.keyToIdCache.get( + CustomObjectCompositeIdentifier.of(customObjectKey, customObjectContainer).toString()) + ).isNotEqualTo(customObjectId) ); verify(client, times(0)).execute(any(CustomObjectQuery.class)); } @@ -371,10 +362,10 @@ void createCustomObject_WithDraft_ShouldCreateCustomObject() { final CustomObjectDraft draft = CustomObjectDraft - .ofUnversionedUpsert(customObjectContainer, customObjectKey,customObjectValue); + .ofUnversionedUpsert(customObjectContainer, customObjectKey, customObjectValue); final Optional> customObjectOptional = - service.upsertCustomObject(draft).toCompletableFuture().join(); + service.upsertCustomObject(draft).toCompletableFuture().join(); assertThat(customObjectOptional).containsSame(mock); verify(client).execute(eq(CustomObjectUpsertCommand.of(draft))); @@ -392,16 +383,12 @@ void createCustomObject_WithRequestException_ShouldNotCreateCustomObject() { when(draftMock.getContainer()).thenReturn(customObjectContainer); when(draftMock.getJavaType()).thenReturn(getCustomObjectJavaTypeForValue(convertToJavaType(JsonNode.class))); - final Optional> customObjectOptional = - service.upsertCustomObject(draftMock).toCompletableFuture().join(); - String expectedMsg = "Failed to create draft with key: '{key='%s', container='%s'}'"; + CompletableFuture future = service.upsertCustomObject(draftMock).toCompletableFuture(); + assertAll( - () -> assertThat(customObjectOptional).isEmpty(), - () -> assertThat(errorMessages).singleElement().asString() - .contains(String.format(expectedMsg, customObjectKey, customObjectContainer)) - .contains("BadRequestException"), - () -> assertThat(errorExceptions).singleElement().isExactlyInstanceOf(BadRequestException.class) + () -> assertThat(future.isCompletedExceptionally()).isTrue(), + () -> assertThat(future).hasFailedWithThrowableThat().isExactlyInstanceOf(BadRequestException.class) ); } @@ -426,10 +413,10 @@ void createCustomObject_WithDraftHasNoContainer_ShouldNotCreateCustomObject() { when(customObjectDraft.getKey()).thenReturn(customObjectKey); when(customObjectDraft.getContainer()).thenReturn(""); when(customObjectDraft.getJavaType()).thenReturn( - getCustomObjectJavaTypeForValue(convertToJavaType(JsonNode.class))); + getCustomObjectJavaTypeForValue(convertToJavaType(JsonNode.class))); final Optional> customObjectOptional = - service.upsertCustomObject(customObjectDraft).toCompletableFuture().join(); + service.upsertCustomObject(customObjectDraft).toCompletableFuture().join(); assertThat(customObjectOptional).isEmpty(); verify(client, times(0)).execute(eq(CustomObjectUpsertCommand.of(customObjectDraft)));