createPortfolioProductWithModel(ProductPortfolio portfolioProduct,
- InvestmentData investmentData) {
-
- String productType = portfolioProduct.getProductType().getValue();
- UUID modelPortfolioUuid = Optional.ofNullable(portfolioProduct.getModelPortfolio())
- .map(InvestorModelPortfolio::getUuid).orElse(null);
- log.info("Creating portfolio product: productType={}, modelPortfolioUuid={}",
- productType, modelPortfolioUuid);
-
- return investmentRestProductPortfolioService.createPortfolioProduct(portfolioProduct,
- List.of(config.getAllocation().getModelPortfolioAllocationAsset()))
- .retry(2)
- .retryWhen(reactor.util.retry.Retry.fixedDelay(1, java.time.Duration.ofSeconds(1)))
- .doOnSuccess(created -> {
- log.info(
- "Successfully created portfolio product: uuid={}, productType={}, modelPortfolio={}",
- created.getUuid(), created.getProductType(), modelPortfolioUuid);
- investmentData.addPortfolioProducts(created);
- })
- .doOnError(throwable -> logPortfolioProductCreationError(productType, throwable));
- }*/
+ /**
+ * Logs portfolio product patch errors with detailed information about the failure.
+ *
+ * @param productUuid the UUID of the portfolio product being patched
+ * @param productName the name of the portfolio product being patched
+ * @param productType the product type being patched
+ * @param throwable the exception that occurred during patching
+ */
+ private void logPortfolioProductPatchError(UUID productUuid, String productName, ProductTypeEnum productType,
+ Throwable throwable) {
+ if (throwable instanceof WebClientResponseException ex) {
+ log.warn(
+ "PATCH portfolio product failed, falling back to existing product: uuid={}, name={}, "
+ + "productType={}, status={}, body={}",
+ productUuid, productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString());
+ } else if (throwable instanceof HttpClientErrorException ex) {
+ log.warn(
+ "PATCH portfolio product failed: uuid={}, name={}, productType={}, status={}, body={}",
+ productUuid, productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString());
+ } else {
+ log.error("PATCH portfolio product failed: uuid={}, name={}, productType={}",
+ productUuid, productName, productType, throwable);
+ }
+ }
/**
* Logs portfolio product creation errors with detailed information about the failure.
*
- * Provides enhanced error context for WebClient exceptions including
+ *
Provides enhanced error context for HTTP client exceptions including
* HTTP status code and response body.
*
+ * @param productName the name of the portfolio product being created
* @param productType the product type being created
* @param throwable the exception that occurred during creation
*/
@@ -416,6 +344,9 @@ private void logPortfolioProductCreationError(String productName, String product
if (throwable instanceof WebClientResponseException ex) {
log.error("Failed to create portfolio product: name={}, productType={}, status={}, body={}",
productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
+ } else if (throwable instanceof HttpClientErrorException ex) {
+ log.error("Failed to create portfolio product: name={}, productType={}, status={}, body={}",
+ productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
} else {
log.error("Failed to create portfolio product: name={}, productType={}",
productName, productType, throwable);
diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java
index 16754671c..8f21aff04 100644
--- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java
+++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java
@@ -1,5 +1,18 @@
package com.backbase.stream.investment.service.resttemplate;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ADVICE_ENGINE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_BADGE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_DESCRIPTION;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTERNAL_ID;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTRA_DATA;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_IMAGE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_MODEL_PORTFOLIO;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_NAME;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ORDER;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_STATUS;
+
import com.backbase.investment.api.service.sync.ApiClient;
import com.backbase.investment.api.service.sync.ApiClient.CollectionFormat;
import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio;
@@ -12,6 +25,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
@@ -23,7 +37,6 @@
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
-import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
/**
@@ -69,16 +82,15 @@ public Mono createPortfolioProduct(ProductPortfolio productPor
}
/**
- * Patches an existing portfolio product via {@code PATCH /service-api/v2/products/portfolio/{uuid}/}.
+ * Updates an existing portfolio product via {@code PATCH /service-api/v2/products/portfolio/{uuid}/}.
*
* @param uuid the UUID of the portfolio product to patch (must not be {@code null})
* @param expand optional fields to expand in the response
* @param updateRequest the stream portfolio product with updated values
* @return {@link Mono} emitting the patched {@link PortfolioProduct}
*/
- public Mono patchPortfolioProduct(String uuid, List expand,
- ProductPortfolio updateRequest)
- throws WebClientResponseException {
+ public Mono updatePortfolioProduct(String uuid, List expand,
+ ProductPortfolio updateRequest) {
log.info("Starting portfolio product patch: uuid={}, name='{}', productType={}",
uuid, updateRequest.getName(), updateRequest.getProductType());
@@ -87,7 +99,7 @@ public Mono patchPortfolioProduct(String uuid, List ex
throw new HttpClientErrorException(HttpStatus.BAD_REQUEST,
"Missing the required parameter 'uuid' when calling patchPortfolioProduct");
}
- return Mono.just(invokePatch(uuid, expand, updateRequest));
+ return Mono.just(invokeUpdate(uuid, expand, updateRequest));
})
.map(patched -> {
log.info("Portfolio product patched successfully: uuid={}, name='{}', productType={}",
@@ -122,7 +134,7 @@ private PortfolioProduct invokeCreate(ProductPortfolio portfolioProduct, List expand, ProductPortfolio data) {
+ private PortfolioProduct invokeUpdate(String uuid, List expand, ProductPortfolio data) {
final Map pathParams = new HashMap();
pathParams.put("uuid", uuid);
final MultiValueMap formParams = productPortfolioParams(data);
@@ -139,7 +151,7 @@ private PortfolioProduct invokePatch(String uuid, List expand, ProductPo
ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference<>() {
};
- return apiClient.invokeAPI("/service-api/v2/products/portfolio/{uuid}/", HttpMethod.PATCH, pathParams,
+ return apiClient.invokeAPI("/service-api/v2/products/portfolio/{uuid}/", HttpMethod.PUT, pathParams,
queryParams, null, headerParams, cookieParams, formParams, localVarAccept, localVarContentType,
new String[]{}, localVarReturnType).getBody();
}
@@ -147,30 +159,30 @@ private PortfolioProduct invokePatch(String uuid, List expand, ProductPo
private @NonNull MultiValueMap productPortfolioParams(ProductPortfolio data) {
final MultiValueMap formParams = new LinkedMultiValueMap<>();
Optional.ofNullable(data.getName())
- .ifPresent(v -> formParams.add("name", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_NAME, v));
Optional.ofNullable(data.getDescription())
- .ifPresent(v -> formParams.add("description", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_DESCRIPTION, v));
Optional.ofNullable(data.getBadge())
- .ifPresent(v -> formParams.add("badge", v));
- Optional.ofNullable(data.getExternalId())
- .ifPresent(v -> formParams.add("external_id", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_BADGE, v));
+ formParams.add(JSON_PROPERTY_EXTERNAL_ID, Optional.ofNullable(data.getExternalId())
+ .orElse(UUID.randomUUID().toString()));
Optional.ofNullable(data.getStatus())
- .ifPresent(v -> formParams.add("status", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_STATUS, v.getValue()));
Optional.ofNullable(data.getOrder())
- .ifPresent(v -> formParams.add("order", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_ORDER, v));
Optional.ofNullable(data.getAdviceEngine())
- .ifPresent(v -> formParams.add("advice_engine", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_ADVICE_ENGINE, v));
Optional.ofNullable(data.getModelPortfolio())
- .ifPresent(v -> formParams.add("model_portfolio",
- Optional.ofNullable(v).map(InvestorModelPortfolio::getUuid).orElse(null)));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_MODEL_PORTFOLIO,
+ Optional.of(v).map(InvestorModelPortfolio::getUuid).map(UUID::toString).orElse(null)));
Optional.ofNullable(data.getProductType())
- .ifPresent(v -> formParams.add("product_type", v.getValue()));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_TYPE, v.getValue()));
Optional.ofNullable(data.getProductCategory())
- .ifPresent(v -> formParams.add("product_category", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_CATEGORY, v));
Optional.ofNullable(data.getExtraData())
- .ifPresent(v -> formParams.add("extra_data", v));
+ .ifPresent(v -> formParams.add(JSON_PROPERTY_EXTRA_DATA, v));
if (ingestProperties.getPortfolio().isIngestImages()) {
- Optional.ofNullable(data.getImageResource()).ifPresent(v -> formParams.add("image", v));
+ Optional.ofNullable(data.getImageResource()).ifPresent(v -> formParams.add(JSON_PROPERTY_IMAGE, v));
}
return formParams;
}
diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java
index b5e89943d..666e97a17 100644
--- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java
+++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java
@@ -2,16 +2,23 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.backbase.investment.api.service.v1.AssetUniverseApi;
+import com.backbase.investment.api.service.v1.model.Deposit;
import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse;
import com.backbase.investment.api.service.v1.model.PortfolioList;
import com.backbase.investment.api.service.v1.model.PortfolioProduct;
import com.backbase.investment.api.service.v1.model.ProductTypeEnum;
import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties;
+import com.backbase.stream.investment.Asset;
import com.backbase.stream.investment.ClientUser;
import com.backbase.stream.investment.InvestmentArrangement;
+import com.backbase.stream.investment.InvestmentAssetData;
import com.backbase.stream.investment.InvestmentData;
import com.backbase.stream.investment.InvestmentTask;
import com.backbase.stream.investment.ModelPortfolio;
@@ -80,6 +87,9 @@ class InvestmentSagaTest {
@Mock
private InvestmentIngestionConfigurationProperties configurationProperties;
+ @Mock
+ private AssetUniverseApi assetUniverseApi;
+
private InvestmentSaga investmentSaga;
private AutoCloseable mocks;
@@ -98,7 +108,7 @@ void setUp() {
investmentPortfolioProductService,
asyncTaskService,
configurationProperties,
- null
+ assetUniverseApi
);
}
@@ -149,6 +159,100 @@ void executeTask_emptyTask_completesNormally() {
.assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
.verifyComplete();
}
+
+ @Test
+ @DisplayName("should skip saga when wealth ingestion is disabled")
+ void executeTask_wealthDisabled_skipsPipeline() {
+ when(configurationProperties.isWealthEnabled()).thenReturn(false);
+ InvestmentTask task = createFullTask();
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isNotEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(clientService, never()).upsertClients(any());
+ verify(investmentPortfolioProductService, never()).upsertInvestmentProducts(any(), any());
+ verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any());
+ }
+ }
+
+ // =========================================================================
+ // loadAssets
+ // =========================================================================
+
+ @Nested
+ @DisplayName("loadAssets")
+ class LoadAssetsTests {
+
+ @Test
+ @DisplayName("should skip asset load when asset universe ingestion is enabled")
+ void loadAssets_assetUniverseEnabled_skipsApiCall() {
+ when(configurationProperties.isAssetUniverseEnabled()).thenReturn(true);
+ InvestmentTask task = createTaskWithAssets();
+ wireTrivialPipelineAfterModelPortfolios();
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("should skip asset load when investment asset data is null")
+ void loadAssets_noAssetData_skipsApiCall() {
+ when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false);
+ InvestmentTask task = createMinimalTask();
+ wireTrivialPipelineAfterModelPortfolios();
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("should skip asset load when asset list is empty")
+ void loadAssets_emptyAssetList_skipsApiCall() {
+ when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false);
+ InvestmentTask task = new InvestmentTask("empty-assets", InvestmentData.builder()
+ .investmentAssetData(InvestmentAssetData.builder().assets(List.of()).build())
+ .investmentArrangements(List.of())
+ .clientUsers(List.of())
+ .portfolios(List.of())
+ .build());
+ wireTrivialPipelineAfterModelPortfolios();
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("should resolve asset UUIDs from asset universe API before upserting clients")
+ void loadAssets_withAssets_resolvesUuids() {
+ when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false);
+ Asset streamAsset = Asset.builder().isin("US0378331005").market("XNAS").currency("USD").build();
+ UUID resolvedUuid = UUID.randomUUID();
+ com.backbase.investment.api.service.v1.model.Asset apiAsset =
+ new com.backbase.investment.api.service.v1.model.Asset(resolvedUuid);
+
+ when(assetUniverseApi.getAsset(eq("US0378331005_XNAS_USD"), isNull(), isNull(), isNull()))
+ .thenReturn(reactor.core.publisher.Mono.just(apiAsset));
+
+ InvestmentTask task = createTaskWithAssets(streamAsset);
+ wireTrivialPipelineAfterModelPortfolios();
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ assertThat(streamAsset.getUuid()).isEqualTo(resolvedUuid);
+ verify(assetUniverseApi).getAsset(eq("US0378331005_XNAS_USD"), isNull(), isNull(), isNull());
+ }
}
// =========================================================================
@@ -261,7 +365,7 @@ void upsertRiskAssessments_error_marksTaskFailed() {
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.just(new OASModelPortfolioResponse()));
when(clientService.upsertClients(any()))
- .thenReturn(Mono.just(List.of(ClientUser.builder().build())));
+ .thenReturn(Mono.just(List.of(clientWithId())));
when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
.thenReturn(Mono.just(List.of()));
when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
@@ -345,11 +449,18 @@ void upsertClients_emptyList_completesSuccessfully() {
@DisplayName("should upsert clients and mark task COMPLETED")
void upsertClients_success() {
InvestmentTask task = createFullTask();
+ ClientUser client = ClientUser.builder()
+ .investmentClientId(UUID.randomUUID())
+ .legalEntityId(LE_INTERNAL_ID)
+ .build();
stubAllServicesSuccess();
+ when(clientService.upsertClients(any())).thenReturn(Mono.just(List.of(client)));
StepVerifier.create(investmentSaga.executeTask(task))
.assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
.verifyComplete();
+
+ assertThat(task.getData().getClientUsers()).containsExactly(client);
}
@Test
@@ -369,16 +480,16 @@ void upsertClients_error_marksTaskFailed() {
}
// =========================================================================
- // upsertArrangements
+ // upsertInvestmentProducts
// =========================================================================
@Nested
- @DisplayName("upsertArrangements")
- class UpsertArrangementsTests {
+ @DisplayName("upsertInvestmentProducts")
+ class UpsertInvestmentProductsTests {
@Test
- @DisplayName("should complete successfully without calling service when arrangement list is empty")
- void upsertArrangements_emptyList_completesSuccessfully() {
+ @DisplayName("should complete successfully when arrangement list is empty")
+ void upsertInvestmentProducts_emptyList_completesSuccessfully() {
InvestmentTask task = createMinimalTask();
wireTrivialPipelineAfterModelPortfolios();
@@ -386,18 +497,37 @@ void upsertArrangements_emptyList_completesSuccessfully() {
.assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
.verifyComplete();
-// verify(investmentPortfolioService).upsertInvestmentProducts(any(), any());
+ verify(investmentPortfolioProductService).upsertInvestmentProducts(any(), any());
+ }
+
+ @Test
+ @DisplayName("should upsert products and register them on investment data")
+ void upsertInvestmentProducts_success_registersProducts() {
+ InvestmentTask task = createFullTask();
+ UUID productUuid = UUID.randomUUID();
+ PortfolioProduct product = new PortfolioProduct(
+ "Robo", null, null, 1, null, "retail", productUuid, null, null, ProductTypeEnum.ROBO_ADVISOR);
+ stubAllServicesSuccess();
+ when(investmentPortfolioProductService.upsertInvestmentProducts(any(), any()))
+ .thenReturn(Mono.just(List.of(product)));
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(investmentPortfolioProductService).upsertInvestmentProducts(task.getData(), task.getData().getInvestmentArrangements());
+ assertThat(task.getData().getIngestedPortfolioProducts()).containsExactly(product);
}
@Test
- @DisplayName("should mark task FAILED when arrangement upsert throws an error")
- void upsertArrangements_error_marksTaskFailed() {
+ @DisplayName("should mark task FAILED when product upsert throws an error")
+ void upsertInvestmentProducts_error_marksTaskFailed() {
InvestmentTask task = createFullTask();
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.just(new OASModelPortfolioResponse()));
when(clientService.upsertClients(any()))
- .thenReturn(Mono.just(List.of(ClientUser.builder().build())));
+ .thenReturn(Mono.just(List.of(clientWithId())));
when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
.thenReturn(Mono.just(List.of()));
when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
@@ -432,6 +562,22 @@ void upsertPortfolios_emptyList_completesSuccessfully() {
verify(investmentPortfolioService).upsertPortfolios(any(), any());
}
+ @Test
+ @DisplayName("should set portfolios on task data after successful upsert")
+ void upsertPortfolios_success_setsPortfoliosOnTask() {
+ InvestmentTask task = createFullTask();
+ InvestmentPortfolio portfolio = InvestmentPortfolio.builder().portfolio(new PortfolioList()).build();
+ stubAllServicesSuccess();
+ when(investmentPortfolioService.upsertPortfolios(any(), any()))
+ .thenReturn(Mono.just(List.of(portfolio)));
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ assertThat(task.getData().getPortfolios()).containsExactly(portfolio);
+ }
+
@Test
@DisplayName("should mark task FAILED when portfolio upsert throws an error")
void upsertPortfolios_error_marksTaskFailed() {
@@ -440,7 +586,7 @@ void upsertPortfolios_error_marksTaskFailed() {
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.just(new OASModelPortfolioResponse()));
when(clientService.upsertClients(any()))
- .thenReturn(Mono.just(List.of(ClientUser.builder().build())));
+ .thenReturn(Mono.just(List.of(clientWithId())));
when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
.thenReturn(Mono.just(List.of()));
when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
@@ -496,7 +642,7 @@ void upsertPortfolioTradingAccounts_error_marksTaskFailed() {
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.just(new OASModelPortfolioResponse()));
when(clientService.upsertClients(any()))
- .thenReturn(Mono.just(List.of(ClientUser.builder().build())));
+ .thenReturn(Mono.just(List.of(clientWithId())));
when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
.thenReturn(Mono.just(List.of()));
when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
@@ -531,6 +677,42 @@ void upsertDepositsAndAllocations_success() {
StepVerifier.create(investmentSaga.executeTask(task))
.assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
.verifyComplete();
+
+ verify(investmentPortfolioService).upsertDeposits(any());
+ verify(investmentPortfolioAllocationService).createDepositAllocation(any());
+ verify(asyncTaskService).checkPriceAsyncTasksFinished(any());
+ }
+
+ @Test
+ @DisplayName("should continue when deposit upsert fails for a portfolio")
+ void upsertDeposits_depositFailure_continuesAndCompletes() {
+ InvestmentTask task = createFullTask();
+ when(investmentModelPortfolioService.upsertModels(any()))
+ .thenReturn(Flux.just(new OASModelPortfolioResponse()));
+ when(clientService.upsertClients(any()))
+ .thenReturn(Mono.just(List.of(clientWithId())));
+ when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
+ .thenReturn(Mono.just(List.of()));
+ when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
+ .thenReturn(Mono.just(List.of()));
+ when(investmentPortfolioProductService.upsertInvestmentProducts(any(), any()))
+ .thenReturn(Mono.just(List.of(new PortfolioProduct())));
+ when(investmentPortfolioService.upsertPortfolios(any(), any()))
+ .thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build())));
+ when(investmentPortfolioService.upsertPortfolioTradingAccounts(any()))
+ .thenReturn(Mono.just(List.of()));
+ when(investmentPortfolioService.upsertDeposits(any()))
+ .thenReturn(Mono.error(new RuntimeException("deposit failed")));
+ when(asyncTaskService.checkPriceAsyncTasksFinished(any()))
+ .thenReturn(Mono.empty());
+ when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any()))
+ .thenReturn(Mono.empty());
+
+ StepVerifier.create(investmentSaga.executeTask(task))
+ .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED))
+ .verifyComplete();
+
+ verify(investmentPortfolioAllocationService, never()).createDepositAllocation(any());
}
@Test
@@ -541,7 +723,7 @@ void upsertDepositsAndAllocations_error_marksTaskFailed() {
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.just(new OASModelPortfolioResponse()));
when(clientService.upsertClients(any()))
- .thenReturn(Mono.just(List.of(ClientUser.builder().build())));
+ .thenReturn(Mono.just(List.of(clientWithId())));
when(investmentRiskQuestionaryService.upsertRiskQuestions(any()))
.thenReturn(Mono.just(List.of()));
when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any()))
@@ -551,9 +733,11 @@ void upsertDepositsAndAllocations_error_marksTaskFailed() {
when(investmentPortfolioService.upsertPortfolios(any(), any()))
.thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build())));
when(investmentPortfolioService.upsertPortfolioTradingAccounts(any()))
- .thenReturn(Mono.empty());
+ .thenReturn(Mono.just(List.of()));
when(investmentPortfolioService.upsertDeposits(any()))
- .thenReturn(Mono.empty());
+ .thenReturn(Mono.just(new Deposit()));
+ when(investmentPortfolioAllocationService.createDepositAllocation(any()))
+ .thenReturn(Mono.just(new Deposit()));
when(asyncTaskService.checkPriceAsyncTasksFinished(any()))
.thenReturn(Mono.empty());
when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any()))
@@ -610,6 +794,20 @@ private InvestmentTask createTaskWithRiskQuestions() {
.build());
}
+ private InvestmentTask createTaskWithAssets(Asset... assets) {
+ return new InvestmentTask("asset-task", InvestmentData.builder()
+ .investmentAssetData(InvestmentAssetData.builder().assets(List.of(assets)).build())
+ .clientUsers(Collections.emptyList())
+ .investmentArrangements(Collections.emptyList())
+ .portfolios(Collections.emptyList())
+ .build());
+ }
+
+ private InvestmentTask createTaskWithAssets() {
+ return createTaskWithAssets(
+ Asset.builder().isin("US0378331005").market("XNAS").currency("USD").build());
+ }
+
private InvestmentTask createFullTask() {
return new InvestmentTask("full-task", InvestmentData.builder()
.clientUsers(List.of(ClientUser.builder()
@@ -648,15 +846,21 @@ private void stubAllServicesSuccess() {
when(investmentPortfolioService.upsertPortfolios(any(), any()))
.thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build())));
when(investmentPortfolioService.upsertPortfolioTradingAccounts(any()))
- .thenReturn(Mono.empty());
+ .thenReturn(Mono.just(List.of()));
when(investmentPortfolioService.upsertDeposits(any()))
- .thenReturn(Mono.empty());
+ .thenReturn(Mono.just(new Deposit()));
+ when(investmentPortfolioAllocationService.createDepositAllocation(any()))
+ .thenReturn(Mono.just(new Deposit()));
when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any()))
.thenReturn(Mono.empty());
when(asyncTaskService.checkPriceAsyncTasksFinished(any()))
.thenReturn(Mono.empty());
}
+ private ClientUser clientWithId() {
+ return ClientUser.builder().investmentClientId(UUID.randomUUID()).legalEntityId(LE_INTERNAL_ID).build();
+ }
+
private void wireTrivialPipelineAfterModelPortfolios() {
when(investmentModelPortfolioService.upsertModels(any()))
.thenReturn(Flux.empty());
@@ -669,7 +873,7 @@ private void wireTrivialPipelineAfterModelPortfolios() {
when(investmentPortfolioService.upsertPortfolios(any(), any()))
.thenReturn(Mono.just(List.of()));
when(investmentPortfolioService.upsertPortfolioTradingAccounts(any()))
- .thenReturn(Mono.empty());
+ .thenReturn(Mono.just(List.of()));
when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any()))
.thenReturn(Mono.empty());
when(asyncTaskService.checkPriceAsyncTasksFinished(any()))
diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java
index 9ec31f73b..0ce6031b7 100644
--- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java
+++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java
@@ -4,18 +4,21 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.backbase.investment.api.service.v1.FinancialAdviceApi;
+import com.backbase.investment.api.service.v1.model.AssetModelPortfolio;
+import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio;
import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse;
-import com.backbase.investment.api.service.v1.model.PaginatedOASModelPortfolioResponseList;
import com.backbase.stream.configuration.IngestConfigProperties;
import com.backbase.stream.investment.Allocation;
import com.backbase.stream.investment.InvestmentData;
import com.backbase.stream.investment.ModelAsset;
import com.backbase.stream.investment.ModelPortfolio;
+import com.backbase.stream.investment.model.PaginatedExpandedModelPortfolioList;
import com.backbase.stream.investment.service.resttemplate.InvestmentRestModelPortfolioService;
import java.util.Collections;
import java.util.List;
@@ -29,6 +32,7 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -48,14 +52,16 @@
*/
class InvestmentModelPortfolioServiceTest {
+ private static final String ALLOCATION_ASSET_EXPAND = "model_portfolio.allocation.asset";
+ private static final int LIST_MODEL_PAGE_SIZE = 50;
+
@Mock
private FinancialAdviceApi financialAdviceApi;
@Mock
private InvestmentRestModelPortfolioService investmentRestModelPortfolioService;
- @Mock
- private IngestConfigProperties ingestConfigProperties;
+ private final IngestConfigProperties ingestConfigProperties = new IngestConfigProperties();
private InvestmentModelPortfolioService service;
@@ -64,7 +70,8 @@ class InvestmentModelPortfolioServiceTest {
@BeforeEach
void setUp() {
mocks = MockitoAnnotations.openMocks(this);
- service = new InvestmentModelPortfolioService(financialAdviceApi, investmentRestModelPortfolioService, ingestConfigProperties);
+ service = new InvestmentModelPortfolioService(
+ financialAdviceApi, investmentRestModelPortfolioService, ingestConfigProperties);
}
@AfterEach
@@ -87,7 +94,7 @@ void upsertModels_nullModelPortfolios_emitsNothing() {
StepVerifier.create(service.upsertModels(data)).verifyComplete();
- verify(financialAdviceApi, never()).listModelPortfolio(
+ verify(financialAdviceApi, never()).listModelPortfolioWithResponseSpec(
any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
verify(investmentRestModelPortfolioService, never()).createModelPortfolio(any());
}
@@ -99,7 +106,7 @@ void upsertModels_emptyModelPortfolios_emitsNothing() {
StepVerifier.create(service.upsertModels(data)).verifyComplete();
- verify(financialAdviceApi, never()).listModelPortfolio(
+ verify(financialAdviceApi, never()).listModelPortfolioWithResponseSpec(
any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@@ -110,7 +117,7 @@ void upsertModels_singlePortfolio_noExisting_createsAndSetsUuid() {
ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Conservative", 3);
+ stubListReturnsEmpty("Conservative");
OASModelPortfolioResponse created = buildResponse(expectedUuid, "Conservative", 3);
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(Mono.just(created));
@@ -132,8 +139,7 @@ void upsertModels_singlePortfolio_existingFound_patches() {
ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Balanced", 5);
- stubListReturnsOne("Balanced", 5, existing);
+ stubListReturnsOne("Balanced", 5, 0.2, existingUuid);
OASModelPortfolioResponse patched = buildResponse(existingUuid, "Balanced", 5);
when(investmentRestModelPortfolioService.patchModelPortfolio(
@@ -156,8 +162,8 @@ void upsertModels_multiplePortfolios_processesAll() {
ModelPortfolio t2 = buildModelPortfolio("Aggressive", 8, 0.05);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(t1, t2)).build();
- stubListReturnsEmpty("Conservative", 3);
- stubListReturnsEmpty("Aggressive", 8);
+ stubListReturnsEmpty("Conservative");
+ stubListReturnsEmpty("Aggressive");
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(
@@ -179,7 +185,7 @@ void upsertModels_createFails_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Conservative", 3);
+ stubListReturnsEmpty("Conservative");
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(Mono.error(new RuntimeException("create failed")));
@@ -198,7 +204,7 @@ void upsertModels_passesCorrectModelPortfolioToRestService() {
.name("Growth").riskLevel(7).cashWeight(0.25).allocations(List.of(allocation)).build();
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Growth", 7);
+ stubListReturnsEmpty("Growth");
ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ModelPortfolio.class);
when(investmentRestModelPortfolioService.createModelPortfolio(requestCaptor.capture()))
@@ -235,7 +241,7 @@ void listExisting_emptyResults_createsNew() {
ModelPortfolio template = buildModelPortfolio("Conservative", 2, 0.15);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Conservative", 2);
+ stubListReturnsEmpty("Conservative");
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(Mono.just(buildResponse(expectedUuid, "Conservative", 2)));
@@ -251,8 +257,7 @@ void listExisting_oneResult_patchesFirstResult() {
ModelPortfolio template = buildModelPortfolio("Moderate", 5, 0.3);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Moderate", 5);
- stubListReturnsOne("Moderate", 5, existing);
+ stubListReturnsOne("Moderate", 5, 0.3, existingUuid);
OASModelPortfolioResponse patched = buildResponse(existingUuid, "Moderate", 5);
when(investmentRestModelPortfolioService.patchModelPortfolio(
eq(existingUuid.toString()), any(ModelPortfolio.class)))
@@ -273,14 +278,13 @@ void listExisting_multipleResults_patchesFirstResult() {
ModelPortfolio template = buildModelPortfolio("Balanced", 6, 0.2);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse first = buildResponse(firstUuid, "Balanced", 6);
- OASModelPortfolioResponse second = buildResponse(secondUuid, "Balanced", 6);
- PaginatedOASModelPortfolioResponseList page = new PaginatedOASModelPortfolioResponseList()
- .count(2).results(List.of(first, second));
- when(financialAdviceApi.listModelPortfolio(
- isNull(), isNull(), isNull(), eq(1), eq("Balanced"),
- isNull(), isNull(), isNull(), eq(6), isNull()))
- .thenReturn(Mono.just(page));
+ InvestorModelPortfolio first = buildInvestorModelPortfolio(firstUuid, "Balanced", 6, 0.2);
+ InvestorModelPortfolio second = buildInvestorModelPortfolio(secondUuid, "Balanced", 6, 0.2);
+ PaginatedExpandedModelPortfolioList page = PaginatedExpandedModelPortfolioList.builder()
+ .count(2)
+ .results(List.of(first, second))
+ .build();
+ stubListReturns(page, "Balanced");
OASModelPortfolioResponse patched = buildResponse(firstUuid, "Balanced", 6);
when(investmentRestModelPortfolioService.patchModelPortfolio(
@@ -300,9 +304,12 @@ void listExisting_apiError_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- when(financialAdviceApi.listModelPortfolio(
- isNull(), isNull(), isNull(), eq(1), eq("Balanced"),
- isNull(), isNull(), isNull(), eq(5), isNull()))
+ WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class);
+ when(financialAdviceApi.listModelPortfolioWithResponseSpec(
+ eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE),
+ eq("Balanced"), isNull(), isNull(), isNull(), isNull(), isNull()))
+ .thenReturn(responseSpec);
+ when(responseSpec.bodyToMono(PaginatedExpandedModelPortfolioList.class))
.thenReturn(Mono.error(new RuntimeException("list API failed")));
StepVerifier.create(service.upsertModels(data))
@@ -326,7 +333,7 @@ void createNew_success_returnsCreatedResponse() {
ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Income", 2);
+ stubListReturnsEmpty("Income");
OASModelPortfolioResponse created = buildResponse(newUuid, "Income", 2);
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(Mono.just(created));
@@ -346,7 +353,7 @@ void createNew_webClientResponseException_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Income", 2);
+ stubListReturnsEmpty("Income");
WebClientResponseException ex = WebClientResponseException.create(
HttpStatus.BAD_REQUEST.value(), "Bad Request", null, null, null);
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
@@ -363,7 +370,7 @@ void createNew_genericException_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- stubListReturnsEmpty("Income", 2);
+ stubListReturnsEmpty("Income");
when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class)))
.thenReturn(Mono.error(new IllegalStateException("unexpected")));
@@ -388,8 +395,7 @@ void patch_success_returnsPatchedResponse() {
ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9);
- stubListReturnsOne("Dynamic", 9, existing);
+ stubListReturnsOne("Dynamic", 9, 0.05, existingUuid);
OASModelPortfolioResponse patched = buildResponse(existingUuid, "Dynamic", 9);
when(investmentRestModelPortfolioService.patchModelPortfolio(
eq(existingUuid.toString()), any(ModelPortfolio.class)))
@@ -413,8 +419,7 @@ void patch_usesCorrectUuidFromExistingPortfolio() {
ModelPortfolio template = buildModelPortfolio("Stable", 4, 0.3);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Stable", 4);
- stubListReturnsOne("Stable", 4, existing);
+ stubListReturnsOne("Stable", 4, 0.3, existingUuid);
ArgumentCaptor uuidCaptor = ArgumentCaptor.forClass(String.class);
OASModelPortfolioResponse patched = buildResponse(existingUuid, "Stable", 4);
@@ -436,8 +441,7 @@ void patch_webClientResponseException_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9);
- stubListReturnsOne("Dynamic", 9, existing);
+ stubListReturnsOne("Dynamic", 9, 0.05, existingUuid);
WebClientResponseException ex = WebClientResponseException.create(
HttpStatus.NOT_FOUND.value(), "Not Found", null, null, null);
when(investmentRestModelPortfolioService.patchModelPortfolio(
@@ -456,8 +460,7 @@ void patch_genericException_propagatesError() {
ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05);
InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build();
- OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9);
- stubListReturnsOne("Dynamic", 9, existing);
+ stubListReturnsOne("Dynamic", 9, 0.05, existingUuid);
when(investmentRestModelPortfolioService.patchModelPortfolio(
eq(existingUuid.toString()), any(ModelPortfolio.class)))
.thenReturn(Mono.error(new RuntimeException("patch failed")));
@@ -486,20 +489,38 @@ private OASModelPortfolioResponse buildResponse(UUID uuid, String name, int risk
return response;
}
- private void stubListReturnsEmpty(String name, int riskLevel) {
- PaginatedOASModelPortfolioResponseList emptyPage = new PaginatedOASModelPortfolioResponseList()
- .count(0).results(Collections.emptyList());
- when(financialAdviceApi.listModelPortfolio(
- any(), any(), any(), eq(1), eq(name), any(), any(), any(), eq(riskLevel), any()))
- .thenReturn(Mono.just(emptyPage));
+ private InvestorModelPortfolio buildInvestorModelPortfolio(
+ UUID uuid, String name, int riskLevel, double cashWeight) {
+ double assetWeight = 1.0 - cashWeight;
+ AssetModelPortfolio allocation = new AssetModelPortfolio().weight(assetWeight);
+ return new InvestorModelPortfolio(
+ uuid, name, cashWeight, riskLevel, List.of(allocation), null, null);
+ }
+
+ private void stubListReturnsEmpty(String name) {
+ PaginatedExpandedModelPortfolioList emptyPage = PaginatedExpandedModelPortfolioList.builder()
+ .count(0)
+ .results(Collections.emptyList())
+ .build();
+ stubListReturns(emptyPage, name);
}
- private void stubListReturnsOne(String name, int riskLevel, OASModelPortfolioResponse response) {
- PaginatedOASModelPortfolioResponseList page = new PaginatedOASModelPortfolioResponseList()
- .count(1).results(List.of(response));
- when(financialAdviceApi.listModelPortfolio(
- any(), any(), any(), eq(1), eq(name), any(), any(), any(), eq(riskLevel), any()))
+ private void stubListReturnsOne(String name, int riskLevel, double cashWeight, UUID uuid) {
+ InvestorModelPortfolio existing = buildInvestorModelPortfolio(uuid, name, riskLevel, cashWeight);
+ PaginatedExpandedModelPortfolioList page = PaginatedExpandedModelPortfolioList.builder()
+ .count(1)
+ .results(List.of(existing))
+ .build();
+ stubListReturns(page, name);
+ }
+
+ private void stubListReturns(PaginatedExpandedModelPortfolioList page, String name) {
+ WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class);
+ when(financialAdviceApi.listModelPortfolioWithResponseSpec(
+ eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE),
+ eq(name), isNull(), isNull(), isNull(), isNull(), isNull()))
+ .thenReturn(responseSpec);
+ when(responseSpec.bodyToMono(PaginatedExpandedModelPortfolioList.class))
.thenReturn(Mono.just(page));
}
}
-
diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java
new file mode 100644
index 000000000..11a1a1faa
--- /dev/null
+++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java
@@ -0,0 +1,408 @@
+package com.backbase.stream.investment.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.backbase.investment.api.service.v1.InvestmentProductsApi;
+import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio;
+import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList;
+import com.backbase.investment.api.service.v1.model.PortfolioProduct;
+import com.backbase.investment.api.service.v1.model.ProductTypeEnum;
+import com.backbase.stream.configuration.IngestConfigProperties;
+import com.backbase.stream.investment.InvestmentArrangement;
+import com.backbase.stream.investment.InvestmentData;
+import com.backbase.stream.investment.ModelPortfolio;
+import com.backbase.stream.investment.ProductPortfolio;
+import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.client.HttpClientErrorException;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * Unit test suite for {@link InvestmentPortfolioProductService}.
+ */
+class InvestmentPortfolioProductServiceTest {
+
+ private static final String ALLOCATION_ASSET_EXPAND = "model_portfolio.allocation.asset";
+ private static final String ORDERING = "-model_portfolio__risk_level";
+ private static final int LIST_PRODUCT_PAGE_SIZE = 50;
+
+ @Mock
+ private InvestmentProductsApi productsApi;
+
+ @Mock
+ private InvestmentModelPortfolioService modelPortfolioService;
+
+ @Mock
+ private InvestmentRestProductPortfolioService investmentRestProductPortfolioService;
+
+ private final IngestConfigProperties ingestConfigProperties = new IngestConfigProperties();
+
+ private InvestmentPortfolioProductService service;
+
+ private AutoCloseable mocks;
+
+ @BeforeEach
+ void setUp() {
+ mocks = MockitoAnnotations.openMocks(this);
+ service = new InvestmentPortfolioProductService(
+ productsApi,
+ ingestConfigProperties,
+ modelPortfolioService,
+ investmentRestProductPortfolioService);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ mocks.close();
+ }
+
+ @Nested
+ @DisplayName("upsertInvestmentProducts")
+ class UpsertInvestmentProductsTests {
+
+ @Test
+ @DisplayName("null arrangements — emits NullPointerException")
+ void nullArrangements_emitsNullPointerException() {
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of()).build();
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, null))
+ .expectError(NullPointerException.class)
+ .verify();
+
+ verify(productsApi, never()).listPortfolioProducts(
+ any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("no portfolio product templates — returns empty list")
+ void noPortfolioProducts_returnsEmptyList() {
+ InvestmentData data = InvestmentData.builder().portfolioProducts(null).build();
+ InvestmentArrangement arrangement = buildArrangement("self-trading", null);
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products).isEmpty())
+ .verifyComplete();
+
+ verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any());
+ assertThat(arrangement.getInvestmentProductId()).isNull();
+ }
+
+ @Test
+ @DisplayName("no existing product — creates via REST service and assigns to arrangement")
+ void noExistingProduct_createsAndAssigns() {
+ UUID createdUuid = UUID.randomUUID();
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading");
+
+ stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING);
+ PortfolioProduct created = buildApiProduct(createdUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1);
+ when(investmentRestProductPortfolioService.createPortfolioProduct(
+ any(ProductPortfolio.class), eq(List.of(ALLOCATION_ASSET_EXPAND))))
+ .thenReturn(Mono.just(created));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> {
+ assertThat(products).hasSize(1);
+ assertThat(products.getFirst().getUuid()).isEqualTo(createdUuid);
+ })
+ .verifyComplete();
+
+ assertThat(arrangement.getInvestmentProductId()).isEqualTo(createdUuid);
+ assertThat(data.getIngestedPortfolioProducts()).containsExactly(created);
+ verify(investmentRestProductPortfolioService, never()).updatePortfolioProduct(any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("existing product found — patches via REST service")
+ void existingProduct_patches() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading");
+
+ PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1);
+ stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing);
+
+ PortfolioProduct patched = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1);
+ when(investmentRestProductPortfolioService.updatePortfolioProduct(
+ eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class)))
+ .thenReturn(Mono.just(patched));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid))
+ .verifyComplete();
+
+ verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any());
+ assertThat(arrangement.getInvestmentProductId()).isEqualTo(existingUuid);
+ }
+
+ @Test
+ @DisplayName("multiple matches — uses last result for patch")
+ void multipleMatches_usesLastResult() {
+ UUID firstUuid = UUID.randomUUID();
+ UUID lastUuid = UUID.randomUUID();
+ ProductPortfolio template = buildTemplate("Robo Plan", ProductTypeEnum.ROBO_ADVISOR);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.ROBO_ADVISOR.getValue(), "Robo Plan");
+
+ PortfolioProduct first = buildApiProduct(firstUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR, 1);
+ PortfolioProduct last = buildApiProduct(lastUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR, 2);
+ stubListReturnsProducts(ProductTypeEnum.ROBO_ADVISOR, first, last);
+
+ when(investmentRestProductPortfolioService.updatePortfolioProduct(
+ eq(lastUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class)))
+ .thenReturn(Mono.just(last));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(lastUuid))
+ .verifyComplete();
+ }
+
+ @Test
+ @DisplayName("duplicate templates by name — processes only one product")
+ void duplicateTemplatesByName_processesOnce() {
+ UUID productUuid = UUID.randomUUID();
+ ProductPortfolio first = buildTemplate("Dedup Product", ProductTypeEnum.SELF_TRADING);
+ ProductPortfolio second = buildTemplate("Dedup Product", ProductTypeEnum.SELF_TRADING);
+ second.setDescription("replacement");
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(first, second)).build();
+
+ InvestmentArrangement arr1 = buildArrangement(ProductTypeEnum.SELF_TRADING.getValue(), null);
+ InvestmentArrangement arr2 = buildArrangement(ProductTypeEnum.SELF_TRADING.getValue(), null);
+
+ stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING);
+ PortfolioProduct created = buildApiProduct(productUuid, "Dedup Product", ProductTypeEnum.SELF_TRADING, 1);
+ when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any()))
+ .thenReturn(Mono.just(created));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arr1, arr2)))
+ .assertNext(products -> assertThat(products).hasSize(1))
+ .verifyComplete();
+
+ verify(investmentRestProductPortfolioService, times(1)).createPortfolioProduct(any(), any());
+ assertThat(arr1.getInvestmentProductId()).isEqualTo(productUuid);
+ assertThat(arr2.getInvestmentProductId()).isEqualTo(productUuid);
+ }
+
+ @Test
+ @DisplayName("arrangement with productPortfolioName — matches product by name")
+ void arrangementWithPortfolioName_matchesByName() {
+ UUID matchingUuid = UUID.randomUUID();
+ UUID otherUuid = UUID.randomUUID();
+ ProductPortfolio targetTemplate = buildTemplate("Target Product", ProductTypeEnum.SELF_TRADING);
+ ProductPortfolio otherTemplate = buildTemplate("Other Product", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder()
+ .portfolioProducts(List.of(targetTemplate, otherTemplate))
+ .build();
+
+ InvestmentArrangement arrangement = InvestmentArrangement.builder()
+ .name("Arrangement")
+ .productTypeExternalId(ProductTypeEnum.SELF_TRADING.getValue())
+ .productPortfolioName("Target Product")
+ .build();
+
+ stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING);
+ PortfolioProduct matching = buildApiProduct(matchingUuid, "Target Product", ProductTypeEnum.SELF_TRADING, 2);
+ PortfolioProduct other = buildApiProduct(otherUuid, "Other Product", ProductTypeEnum.SELF_TRADING, 1);
+
+ when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any()))
+ .thenReturn(Mono.just(matching), Mono.just(other));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products).hasSize(2))
+ .verifyComplete();
+
+ assertThat(arrangement.getInvestmentProductId()).isEqualTo(matchingUuid);
+ }
+
+ @Test
+ @DisplayName("template with model portfolio — upserts model before product create")
+ void templateWithModelPortfolio_upsertsModelFirst() {
+ UUID modelUuid = UUID.randomUUID();
+ UUID productUuid = UUID.randomUUID();
+ InvestorModelPortfolio investorModel = new InvestorModelPortfolio(
+ null, "Growth Model", 0.25, 7, null, null, null);
+ ProductPortfolio template = buildTemplate("Robo Product", ProductTypeEnum.ROBO_ADVISOR);
+ template.setModelPortfolio(investorModel);
+ template.setProductCategory("retail");
+
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.ROBO_ADVISOR.getValue(), "Robo Product");
+
+ ModelPortfolio upsertedModel = ModelPortfolio.builder()
+ .uuid(modelUuid).name("Growth Model").riskLevel(7).cashWeight(0.25).build();
+ when(modelPortfolioService.upsertModelPortfolio(investorModel)).thenReturn(Mono.just(upsertedModel));
+
+ stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR);
+ PortfolioProduct created = buildApiProduct(productUuid, "Robo Product", ProductTypeEnum.ROBO_ADVISOR, 1);
+ when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any()))
+ .thenReturn(Mono.just(created));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products).hasSize(1))
+ .verifyComplete();
+
+ verify(modelPortfolioService).upsertModelPortfolio(investorModel);
+ ArgumentCaptor templateCaptor = ArgumentCaptor.forClass(ProductPortfolio.class);
+ verify(investmentRestProductPortfolioService).createPortfolioProduct(
+ templateCaptor.capture(), eq(List.of(ALLOCATION_ASSET_EXPAND)));
+ assertThat(templateCaptor.getValue().getModelPortfolio()).isNotNull();
+ assertThat(templateCaptor.getValue().getModelPortfolio().getUuid()).isEqualTo(modelUuid);
+ }
+
+ @Test
+ @DisplayName("patch fails with HttpClientErrorException — falls back to existing product")
+ void patchFailsWithHttpClientError_fallsBackToExisting() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading");
+
+ PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1);
+ stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing);
+
+ HttpClientErrorException ex = HttpClientErrorException.create(
+ HttpStatus.BAD_REQUEST, "Bad Request", null, null, null);
+ when(investmentRestProductPortfolioService.updatePortfolioProduct(any(), any(), any()))
+ .thenReturn(Mono.error(ex));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> {
+ assertThat(products).hasSize(1);
+ assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid);
+ })
+ .verifyComplete();
+
+ verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any());
+ assertThat(arrangement.getInvestmentProductId()).isEqualTo(existingUuid);
+ }
+
+ @Test
+ @DisplayName("list API failure — propagates error")
+ void listApiFailure_propagatesError() {
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+
+ when(productsApi.listPortfolioProducts(
+ eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE),
+ isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING),
+ eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())), isNull(), isNull()))
+ .thenReturn(Mono.error(new RuntimeException("list failed")));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement(
+ ProductTypeEnum.SELF_TRADING.getValue(), null))))
+ .expectError(RuntimeException.class)
+ .verify();
+ }
+
+ @Test
+ @DisplayName("create API failure — propagates error")
+ void createApiFailure_propagatesError() {
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+
+ stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING);
+ when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any()))
+ .thenReturn(Mono.error(new IllegalStateException("create failed")));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement(
+ ProductTypeEnum.SELF_TRADING.getValue(), null))))
+ .expectError(IllegalStateException.class)
+ .verify();
+ }
+
+ @Test
+ @DisplayName("no matching product type for arrangement — arrangement product id stays null")
+ void noMatchingProductType_arrangementNotAssigned() {
+ ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING);
+ InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build();
+ InvestmentArrangement arrangement = buildArrangement(
+ ProductTypeEnum.ROBO_ADVISOR.getValue(), null);
+
+ stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING);
+ UUID productUuid = UUID.randomUUID();
+ when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any()))
+ .thenReturn(Mono.just(buildApiProduct(
+ productUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1)));
+
+ StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement)))
+ .assertNext(products -> assertThat(products).hasSize(1))
+ .verifyComplete();
+
+ assertThat(arrangement.getInvestmentProductId()).isNull();
+ }
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private ProductPortfolio buildTemplate(String name, ProductTypeEnum productType) {
+ ProductPortfolio template = new ProductPortfolio();
+ template.setName(name);
+ template.setProductType(productType);
+ template.setProductCategory("default");
+ return template;
+ }
+
+ private PortfolioProduct buildApiProduct(
+ UUID uuid, String name, ProductTypeEnum productType, Integer order) {
+ return new PortfolioProduct(
+ name, null, null, order, null, "default", uuid, null, null, productType);
+ }
+
+ private InvestmentArrangement buildArrangement(String productTypeExternalId, String productPortfolioName) {
+ return InvestmentArrangement.builder()
+ .name("Test Arrangement")
+ .productTypeExternalId(productTypeExternalId)
+ .productPortfolioName(productPortfolioName)
+ .build();
+ }
+
+ private void stubListReturnsEmpty(ProductTypeEnum productType) {
+ PaginatedPortfolioProductList emptyPage = new PaginatedPortfolioProductList()
+ .count(0)
+ .results(Collections.emptyList());
+ when(productsApi.listPortfolioProducts(
+ eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE),
+ isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING),
+ eq(List.of(productType.getValue())), isNull(), isNull()))
+ .thenReturn(Mono.just(emptyPage));
+ }
+
+ private void stubListReturnsProducts(ProductTypeEnum productType, PortfolioProduct... products) {
+ PaginatedPortfolioProductList page = new PaginatedPortfolioProductList()
+ .count(products.length)
+ .results(List.of(products));
+ when(productsApi.listPortfolioProducts(
+ eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE),
+ isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING),
+ eq(List.of(productType.getValue())), isNull(), isNull()))
+ .thenReturn(Mono.just(page));
+ }
+}
diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java
index bee9a2ce5..a878755ed 100644
--- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java
+++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java
@@ -3,9 +3,12 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.backbase.investment.api.service.sync.ApiClient;
+import com.backbase.investment.api.service.v1.model.OASModelPortfolioRequestDataRequest;
import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse;
import com.backbase.stream.investment.ModelPortfolio;
import java.util.UUID;
@@ -14,19 +17,29 @@
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import reactor.test.StepVerifier;
@ExtendWith(MockitoExtension.class)
class InvestmentRestModelPortfolioServiceTest {
+ private static final String FORM_PARAM_DATA = "data";
+
@Mock
private ApiClient apiClient;
+ @Captor
+ @SuppressWarnings("unchecked")
+ private ArgumentCaptor> formParamsCaptor;
+
private InvestmentRestModelPortfolioService service;
@BeforeEach
@@ -52,8 +65,9 @@ void successfulCreateReturnsResponse() {
response.setName("Conservative");
response.setRiskLevel(3);
- when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(),
- any())).thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
+ when(apiClient.invokeAPI(anyString(), eq(HttpMethod.POST), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
StepVerifier.create(service.createModelPortfolio(modelPortfolio))
.assertNext(result -> {
@@ -62,6 +76,29 @@ void successfulCreateReturnsResponse() {
assertThat(result.getRiskLevel()).isEqualTo(3);
})
.verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().containsKey(FORM_PARAM_DATA)).isTrue();
+ assertThat(formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA))
+ .isInstanceOf(OASModelPortfolioRequestDataRequest.class);
+ OASModelPortfolioRequestDataRequest dataRequest =
+ (OASModelPortfolioRequestDataRequest) formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA);
+ assertThat(dataRequest.getName()).isEqualTo("Conservative");
+ assertThat(dataRequest.getRiskLevel()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("create sends multipart form field named 'data'")
+ void createUsesDataFormParamName() {
+ ModelPortfolio modelPortfolio = buildModelPortfolio("Growth", 7);
+
+ when(apiClient.invokeAPI(anyString(), eq(HttpMethod.POST), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ new OASModelPortfolioResponse(UUID.randomUUID()), HttpStatus.OK));
+
+ StepVerifier.create(service.createModelPortfolio(modelPortfolio)).expectNextCount(1).verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().keySet()).containsExactly(FORM_PARAM_DATA);
}
@Test
@@ -109,8 +146,9 @@ void successfulPatchReturnsResponse() {
response.setName("Dynamic");
response.setRiskLevel(8);
- when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(),
- any())).thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
+ when(apiClient.invokeAPI(anyString(), eq(HttpMethod.PUT), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
StepVerifier.create(service.patchModelPortfolio(existingUuid.toString(), modelPortfolio))
.assertNext(result -> {
@@ -119,6 +157,35 @@ void successfulPatchReturnsResponse() {
assertThat(result.getRiskLevel()).isEqualTo(8);
})
.verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().containsKey(FORM_PARAM_DATA)).isTrue();
+ assertThat(formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA))
+ .isInstanceOf(OASModelPortfolioRequestDataRequest.class);
+ OASModelPortfolioRequestDataRequest dataRequest =
+ (OASModelPortfolioRequestDataRequest) formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA);
+ assertThat(dataRequest.getName()).isEqualTo("Dynamic");
+ assertThat(dataRequest.getRiskLevel()).isEqualTo(8);
+ }
+
+ @Test
+ @DisplayName("patch sends multipart form field named 'data'")
+ void patchUsesDataFormParamName() {
+ UUID existingUuid = UUID.randomUUID();
+ ModelPortfolio modelPortfolio = buildModelPortfolio("Income", 2);
+
+ when(apiClient.invokeAPI(anyString(), eq(HttpMethod.PUT), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ new OASModelPortfolioResponse(existingUuid), HttpStatus.OK));
+
+ StepVerifier.create(service.patchModelPortfolio(existingUuid.toString(), modelPortfolio))
+ .expectNextCount(1)
+ .verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().keySet()).containsExactly(FORM_PARAM_DATA);
+ verify(apiClient).invokeAPI(
+ eq("/service-api/v2/advice-engines/model-portfolio/model_portfolios/{uuid}/"),
+ eq(HttpMethod.PUT), any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java
new file mode 100644
index 000000000..806102aac
--- /dev/null
+++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java
@@ -0,0 +1,329 @@
+package com.backbase.stream.investment.service.resttemplate;
+
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ADVICE_ENGINE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_DESCRIPTION;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTERNAL_ID;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTRA_DATA;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_IMAGE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_MODEL_PORTFOLIO;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_NAME;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ORDER;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE;
+import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_STATUS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.backbase.investment.api.service.sync.ApiClient;
+import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio;
+import com.backbase.investment.api.service.v1.model.PortfolioProduct;
+import com.backbase.investment.api.service.v1.model.PortfolioProductStatusEnum;
+import com.backbase.investment.api.service.v1.model.ProductTypeEnum;
+import com.backbase.stream.configuration.IngestConfigProperties;
+import com.backbase.stream.investment.ProductPortfolio;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.HttpClientErrorException;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class InvestmentRestProductPortfolioServiceTest {
+
+ private static final String CREATE_PATH = "/service-api/v2/products/portfolio/";
+ private static final String UPDATE_PATH = "/service-api/v2/products/portfolio/{uuid}/";
+ private static final List EXPAND = List.of("model_portfolio.allocation.asset");
+
+ @Mock
+ private ApiClient apiClient;
+
+ @Captor
+ @SuppressWarnings("unchecked")
+ private ArgumentCaptor> formParamsCaptor;
+
+ @Captor
+ @SuppressWarnings("unchecked")
+ private ArgumentCaptor> queryParamsCaptor;
+
+ private final IngestConfigProperties ingestProperties = new IngestConfigProperties();
+
+ private InvestmentRestProductPortfolioService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new InvestmentRestProductPortfolioService(apiClient, ingestProperties);
+ when(apiClient.selectHeaderAccept(any())).thenReturn(List.of(MediaType.APPLICATION_JSON));
+ when(apiClient.selectHeaderContentType(any())).thenReturn(MediaType.MULTIPART_FORM_DATA);
+ when(apiClient.parameterToMultiValueMap(any(), eq("expand"), any()))
+ .thenReturn(new LinkedMultiValueMap<>());
+ }
+
+ @Nested
+ @DisplayName("createPortfolioProduct")
+ class CreatePortfolioProduct {
+
+ @Test
+ @DisplayName("successful create returns the created PortfolioProduct")
+ void successfulCreateReturnsResponse() {
+ UUID createdUuid = UUID.randomUUID();
+ ProductPortfolio template = buildFullTemplate(createdUuid);
+ PortfolioProduct response = buildApiProduct(createdUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR);
+
+ when(apiClient.invokeAPI(eq(CREATE_PATH), eq(HttpMethod.POST), any(), queryParamsCaptor.capture(),
+ any(), any(), any(), formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND))
+ .assertNext(result -> {
+ assertThat(result.getUuid()).isEqualTo(createdUuid);
+ assertThat(result.getName()).isEqualTo("Robo Plan");
+ assertThat(result.getProductType()).isEqualTo(ProductTypeEnum.ROBO_ADVISOR);
+ })
+ .verifyComplete();
+
+ assertFormParamsMatchTemplate(formParamsCaptor.getValue(), createdUuid);
+ assertThat(queryParamsCaptor.getValue()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("create uses direct JSON_PROPERTY form param names")
+ void createUsesDirectFormParamNames() {
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+
+ when(apiClient.invokeAPI(eq(CREATE_PATH), eq(HttpMethod.POST), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete();
+
+ MultiValueMap formParams = formParamsCaptor.getValue();
+ assertThat(formParams.keySet()).contains(
+ JSON_PROPERTY_NAME,
+ JSON_PROPERTY_DESCRIPTION,
+ JSON_PROPERTY_EXTERNAL_ID,
+ JSON_PROPERTY_STATUS,
+ JSON_PROPERTY_ORDER,
+ JSON_PROPERTY_ADVICE_ENGINE,
+ JSON_PROPERTY_MODEL_PORTFOLIO,
+ JSON_PROPERTY_PRODUCT_TYPE,
+ JSON_PROPERTY_PRODUCT_CATEGORY,
+ JSON_PROPERTY_EXTRA_DATA);
+ assertThat(formParams.getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan");
+ assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor");
+ assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_CATEGORY)).isEqualTo("retail");
+ assertThat(formParams.getFirst(JSON_PROPERTY_MODEL_PORTFOLIO))
+ .isEqualTo(template.getModelPortfolio().getUuid().toString());
+ }
+
+ @Test
+ @DisplayName("create includes image form param when ingestImages is enabled")
+ void createIncludesImageWhenIngestEnabled() {
+ ingestProperties.getPortfolio().setIngestImages(true);
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+ template.setImageResource(new ByteArrayResource("logo".getBytes()) {
+ @Override
+ public String getFilename() {
+ return "logo.png";
+ }
+ });
+
+ when(apiClient.invokeAPI(any(), eq(HttpMethod.POST), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().containsKey(JSON_PROPERTY_IMAGE)).isTrue();
+ }
+
+ @Test
+ @DisplayName("create omits image form param when ingestImages is disabled")
+ void createOmitsImageWhenIngestDisabled() {
+ ingestProperties.getPortfolio().setIngestImages(false);
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+ template.setImageResource(new ByteArrayResource("logo".getBytes()));
+
+ when(apiClient.invokeAPI(any(), eq(HttpMethod.POST), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().containsKey(JSON_PROPERTY_IMAGE)).isFalse();
+ }
+
+ @Test
+ @DisplayName("API failure propagates as error signal")
+ void apiFailurePropagatesError() {
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+
+ when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenThrow(new RuntimeException("Network error"));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND))
+ .expectErrorMessage("Network error")
+ .verify();
+ }
+
+ @Test
+ @DisplayName("API returns null body propagates NullPointerException")
+ void nullResponseBodyPropagatesNullPointer() {
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+
+ when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(HttpStatus.OK));
+
+ StepVerifier.create(service.createPortfolioProduct(template, EXPAND))
+ .expectError(NullPointerException.class)
+ .verify();
+ }
+ }
+
+ @Nested
+ @DisplayName("updatePortfolioProduct")
+ class UpdatePortfolioProduct {
+
+ @Test
+ @DisplayName("successful update returns the patched PortfolioProduct")
+ void successfulUpdateReturnsResponse() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildFullTemplate(existingUuid);
+ PortfolioProduct response = buildApiProduct(existingUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR);
+
+ when(apiClient.invokeAPI(eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), queryParamsCaptor.capture(),
+ any(), any(), any(), formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
+
+ StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template))
+ .assertNext(result -> {
+ assertThat(result.getUuid()).isEqualTo(existingUuid);
+ assertThat(result.getName()).isEqualTo("Robo Plan");
+ })
+ .verifyComplete();
+
+ assertFormParamsMatchTemplate(formParamsCaptor.getValue(), existingUuid);
+ verify(apiClient).invokeAPI(
+ eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("update uses direct JSON_PROPERTY form param names")
+ void updateUsesDirectFormParamNames() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildFullTemplate(existingUuid);
+
+ when(apiClient.invokeAPI(eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), any(), any(), any(), any(),
+ formParamsCaptor.capture(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(
+ buildApiProduct(existingUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK));
+
+ StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template))
+ .expectNextCount(1)
+ .verifyComplete();
+
+ assertThat(formParamsCaptor.getValue().getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan");
+ assertThat(formParamsCaptor.getValue().getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor");
+ }
+
+ @Test
+ @DisplayName("null UUID emits HttpClientErrorException with BAD_REQUEST status")
+ void nullUuidEmitsHttpClientErrorException() {
+ ProductPortfolio template = buildFullTemplate(UUID.randomUUID());
+
+ StepVerifier.create(service.updatePortfolioProduct(null, EXPAND, template))
+ .expectErrorSatisfies(err -> {
+ assertThat(err).isInstanceOf(HttpClientErrorException.class);
+ HttpClientErrorException ex = (HttpClientErrorException) err;
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ })
+ .verify();
+ }
+
+ @Test
+ @DisplayName("API failure propagates as error signal")
+ void apiFailurePropagatesError() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildFullTemplate(existingUuid);
+
+ when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenThrow(new RuntimeException("Connection refused"));
+
+ StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template))
+ .expectErrorMessage("Connection refused")
+ .verify();
+ }
+
+ @Test
+ @DisplayName("API returns null body propagates NullPointerException")
+ void nullResponseBodyPropagatesNullPointer() {
+ UUID existingUuid = UUID.randomUUID();
+ ProductPortfolio template = buildFullTemplate(existingUuid);
+
+ when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+ .thenReturn(new ResponseEntity<>(HttpStatus.OK));
+
+ StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template))
+ .expectError(NullPointerException.class)
+ .verify();
+ }
+ }
+
+ private ProductPortfolio buildFullTemplate(UUID modelPortfolioUuid) {
+ ProductPortfolio template = new ProductPortfolio();
+ template.setName("Robo Plan");
+ template.setDescription("Robo description");
+ template.setProductType(ProductTypeEnum.ROBO_ADVISOR);
+ template.setProductCategory("retail");
+ template.setOrder(1);
+ template.setExternalId("ext-robo-001");
+ template.setStatus(PortfolioProductStatusEnum.ACTIVE);
+ template.setAdviceEngine("default-engine");
+ template.setExtraData(Map.of("key", "value"));
+ template.setModelPortfolio(new InvestorModelPortfolio(
+ modelPortfolioUuid, "Growth Model", 0.25, 7, null, null, null));
+ return template;
+ }
+
+ private void assertFormParamsMatchTemplate(MultiValueMap formParams, UUID modelPortfolioUuid) {
+ assertThat(formParams.getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan");
+ assertThat(formParams.getFirst(JSON_PROPERTY_DESCRIPTION)).isEqualTo("Robo description");
+ assertThat(formParams.getFirst(JSON_PROPERTY_EXTERNAL_ID)).isEqualTo("ext-robo-001");
+ assertThat(formParams.getFirst(JSON_PROPERTY_STATUS)).isEqualTo(PortfolioProductStatusEnum.ACTIVE.getValue());
+ assertThat(formParams.getFirst(JSON_PROPERTY_ORDER)).isEqualTo(1);
+ assertThat(formParams.getFirst(JSON_PROPERTY_ADVICE_ENGINE)).isEqualTo("default-engine");
+ assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor");
+ assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_CATEGORY)).isEqualTo("retail");
+ assertThat(formParams.getFirst(JSON_PROPERTY_MODEL_PORTFOLIO)).isEqualTo(modelPortfolioUuid.toString());
+ assertThat(formParams.getFirst(JSON_PROPERTY_EXTRA_DATA)).isEqualTo(Map.of("key", "value"));
+ }
+
+ private PortfolioProduct buildApiProduct(UUID uuid, String name, ProductTypeEnum productType) {
+ return new PortfolioProduct(
+ name, null, null, 1, null, "retail", uuid, null, null, productType);
+ }
+}