diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d8210c3..b28fa1915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [10.15.0] +### Changed +- Fix for Investment Portfolio products create failing due to uniques validation + ## [10.14.0] ### Changed - Fix for Investment Portfolio trading accounts patch failing due to uniques validation diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java index 86b6b6d67..84bb02896 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java @@ -12,7 +12,6 @@ import com.backbase.stream.investment.ProductPortfolio; import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService; import com.backbase.stream.investment.service.resttemplate.RestTemplateModelPortfolioMapper; -import java.time.Duration; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -23,12 +22,12 @@ import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.mapstruct.factory.Mappers; +import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.util.retry.Retry; /** * Service wrapper around {@link InvestmentProductsApi} and {@link InvestmentRestProductPortfolioService} providing @@ -106,6 +105,12 @@ public Mono> upsertInvestmentProducts(InvestmentData inve .map(InvestorModelPortfolio::getName).orElse(""))) .doOnError(throwable -> log.error("Failed to upsert portfolio product: name={}, productType={}", p.getName(), p.getProductType(), throwable)) + .onErrorResume(throwable -> { + log.warn("Skipping portfolio product upsert, continuing with remaining products: name={}, " + + "externalId={}, productType={}", + p.getName(), p.getExternalId(), p.getProductType(), throwable); + return Mono.empty(); + }) ) .collectList() .map(products -> { @@ -195,6 +200,31 @@ private Collection distinctProducts(List pro } private Mono listExistingPortfolioProducts(ProductPortfolio portfolioProduct) { + ProductTypeEnum productType = portfolioProduct.getProductType(); + + Mono byExternalId = Mono.empty(); + if (StringUtils.hasText(portfolioProduct.getExternalId())) { + byExternalId = productsApi.listPortfolioProducts( + List.of(config.getAllocation().getModelPortfolioAllocationAsset()), + null, portfolioProduct.getExternalId(), null, 1, null, null, null, null, null, null, + List.of(productType.getValue()), null, null) + .flatMap(response -> { + List results = Objects.requireNonNullElse(response.getResults(), List.of()); + if (results.isEmpty()) { + return Mono.empty(); + } + PortfolioProduct existingProduct = results.getFirst(); + log.info("Found existing portfolio product: uuid={}, externalId={}, name={}, productType={}", + existingProduct.getUuid(), portfolioProduct.getExternalId(), existingProduct.getName(), + productType); + return Mono.just(existingProduct); + }); + } + + return byExternalId.switchIfEmpty(Mono.defer(() -> listExistingPortfolioProductsByName(portfolioProduct))); + } + + private Mono listExistingPortfolioProductsByName(ProductPortfolio portfolioProduct) { Integer riskLevel = Optional.ofNullable(portfolioProduct.getModelPortfolio()) .map(InvestorModelPortfolio::getRiskLevel).orElse(null); @@ -283,6 +313,7 @@ private Mono updateExistingPortfolioProduct(PortfolioProduct e }) .doOnError(throwable -> logPortfolioProductPatchError( productUuid, portfolioProduct.getName(), portfolioProduct.getProductType(), throwable)) + .onErrorResume(WebClientResponseException.class, ex -> Mono.just(existingProduct)) .onErrorResume(HttpClientErrorException.class, ex -> Mono.just(existingProduct)); } @@ -304,17 +335,44 @@ private Mono createPortfolioProductWithModel(ProductPortfolio return investmentRestProductPortfolioService.createPortfolioProduct(portfolioProduct, List.of(config.getAllocation().getModelPortfolioAllocationAsset())) - .retryWhen(Retry.fixedDelay(2, Duration.ofSeconds(1))) .doOnSuccess(created -> { log.debug( "Successfully created portfolio product: uuid={}, name={}, productType={}, modelPortfolioUuid={}", created.getUuid(), created.getName(), created.getProductType(), modelPortfolioUuid); investmentData.addPortfolioProducts(created); }) + .onErrorResume(throwable -> { + if (!StringUtils.hasText(portfolioProduct.getExternalId()) || !isDuplicateExternalIdError(throwable)) { + return Mono.error(throwable); + } + log.warn("Portfolio product already exists for externalId={}, falling back to patch: name={}", + portfolioProduct.getExternalId(), portfolioProduct.getName()); + return listExistingPortfolioProducts(portfolioProduct) + .flatMap(existing -> updateExistingPortfolioProduct(existing, portfolioProduct, investmentData)) + .switchIfEmpty(Mono.error(throwable)); + }) .doOnError(throwable -> logPortfolioProductCreationError( portfolioProduct.getName(), productType, throwable)); } + private boolean isDuplicateExternalIdError(Throwable throwable) { + if (throwable instanceof WebClientResponseException ex + && HttpStatus.BAD_REQUEST.equals(ex.getStatusCode())) { + return containsDuplicateExternalIdMessage(ex.getResponseBodyAsString()); + } + if (throwable instanceof HttpClientErrorException ex + && HttpStatus.BAD_REQUEST.equals(ex.getStatusCode())) { + return containsDuplicateExternalIdMessage(ex.getResponseBodyAsString()); + } + return false; + } + + private static boolean containsDuplicateExternalIdMessage(String body) { + return body != null + && body.contains("external_id") + && body.contains("already exists"); + } + /** * Logs portfolio product patch errors with detailed information about the failure. * 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 index 9741912f4..bc442587b 100644 --- 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 @@ -20,20 +20,27 @@ import com.backbase.stream.investment.ModelPortfolio; import com.backbase.stream.investment.ProductPortfolio; import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.UUID; +import java.util.stream.Stream; 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -82,6 +89,15 @@ void tearDown() throws Exception { mocks.close(); } + static Stream createBadRequestNotDuplicateExternalIdCases() { + return Stream.of( + Arguments.of("without duplicate externalId message", "{\"code\":\"INVALID_INPUT\"}"), + Arguments.of("missing external_id token", "{\"errors\":{\"name\":[\"already exists\"]}}"), + Arguments.of("missing already exists token", "{\"errors\":{\"external_id\":[\"invalid value\"]}}"), + Arguments.of("null response body", null) + ); + } + @Nested @DisplayName("upsertInvestmentProducts") class UpsertInvestmentProductsTests { @@ -310,8 +326,8 @@ void patchFailsWithHttpClientError_fallsBackToExisting() { } @Test - @DisplayName("list API failure — propagates error") - void listApiFailure_propagatesError() { + @DisplayName("list API failure — skips product and completes batch") + void listApiFailure_skipsProduct() { ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); @@ -323,13 +339,13 @@ void listApiFailure_propagatesError() { StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( ProductTypeEnum.SELF_TRADING.getValue(), null)))) - .expectError(RuntimeException.class) - .verify(); + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); } @Test - @DisplayName("create API failure — propagates error") - void createApiFailure_propagatesError() { + @DisplayName("create API failure — skips product and completes batch") + void createApiFailure_skipsProduct() { ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); @@ -339,8 +355,409 @@ void createApiFailure_propagatesError() { StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( ProductTypeEnum.SELF_TRADING.getValue(), null)))) - .expectError(IllegalStateException.class) - .verify(); + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("one product fails — continues with remaining products") + void oneProductFails_continuesWithRemaining() { + UUID successUuid = UUID.randomUUID(); + ProductPortfolio failing = buildTemplate("Failing Product", ProductTypeEnum.SELF_TRADING); + ProductPortfolio succeeding = buildTemplate("Working Product", ProductTypeEnum.ROBO_ADVISOR); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(failing, succeeding)).build(); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenAnswer(invocation -> { + ProductPortfolio template = invocation.getArgument(0); + if ("Failing Product".equals(template.getName())) { + return Mono.error(new IllegalStateException("create failed")); + } + return Mono.just(buildApiProduct(successUuid, "Working Product", ProductTypeEnum.ROBO_ADVISOR, 1)); + }); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of( + buildArrangement(ProductTypeEnum.SELF_TRADING.getValue(), "Failing Product"), + buildArrangement(ProductTypeEnum.ROBO_ADVISOR.getValue(), "Working Product")))) + .assertNext(products -> { + assertThat(products).hasSize(1); + assertThat(products.getFirst().getUuid()).isEqualTo(successUuid); + }) + .verifyComplete(); + } + + @Test + @DisplayName("existing product found by externalId when name differs — patches without create") + void existingProduct_foundByExternalIdWhenNameDiffers_patches() { + UUID existingUuid = UUID.randomUUID(); + String externalId = "ext-portfolio-self-trading-001"; + ProductPortfolio template = buildTemplate("Self-Trading Portfolio", ProductTypeEnum.SELF_TRADING); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self-Trading Portfolio"); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Legacy Self Trading Name", + ProductTypeEnum.SELF_TRADING, 1); + existing.setExternalId(externalId); + stubListByExternalIdReturns(externalId, ProductTypeEnum.SELF_TRADING, existing); + + PortfolioProduct patched = buildApiProduct(existingUuid, "Self-Trading Portfolio", + 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()); + verify(productsApi, never()).listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), + any(), any(), any(), any(), any(), eq(ORDERING), any(), any(), any()); + assertThat(arrangement.getInvestmentProductId()).isEqualTo(existingUuid); + } + + @Test + @DisplayName("externalId lookup empty — falls back to name search") + void externalIdLookupEmpty_fallsBackToNameSearch() { + UUID existingUuid = UUID.randomUUID(); + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Balanced", ProductTypeEnum.ROBO_ADVISOR, 13); + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsProducts(ProductTypeEnum.ROBO_ADVISOR, existing); + + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(existing)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any()); + } + + @Test + @DisplayName("create duplicate externalId (HttpClient) — falls back to patch") + void createDuplicateExternalId_httpClient_fallsBackToPatch() { + UUID existingUuid = UUID.randomUUID(); + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Balanced", ProductTypeEnum.ROBO_ADVISOR, 13); + existing.setExternalId(externalId); + stubListByExternalIdReturnsEmptyThenProduct(externalId, ProductTypeEnum.ROBO_ADVISOR, existing); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(duplicateExternalIdHttpClientException())); + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(existing)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("create duplicate externalId (WebClient) — falls back to patch") + void createDuplicateExternalId_webClient_fallsBackToPatch() { + UUID existingUuid = UUID.randomUUID(); + String externalId = "ext-opportunity-hf-savings-plan-007"; + ProductPortfolio template = buildTemplate("Opportunity Horizon Fund", ProductTypeEnum.SAVINGS_PLAN); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct( + existingUuid, "Opportunity Horizon Fund", ProductTypeEnum.SAVINGS_PLAN, 7); + existing.setExternalId(externalId); + stubListByExternalIdReturnsEmptyThenProduct(externalId, ProductTypeEnum.SAVINGS_PLAN, existing); + stubListReturnsEmpty(ProductTypeEnum.SAVINGS_PLAN); + + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(duplicateExternalIdWebClientException())); + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(existing)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SAVINGS_PLAN.getValue(), "Opportunity Horizon Fund")))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("create duplicate externalId but lookup empty — skips product") + void createDuplicateExternalId_lookupEmpty_skipsProduct() { + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(duplicateExternalIdHttpClientException())); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create without externalId — duplicate detection not applied") + void createWithoutExternalId_nonDuplicateError_skipsProduct() { + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(duplicateExternalIdHttpClientException())); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @ParameterizedTest(name = "create bad request ({0}) — skips product") + @MethodSource("com.backbase.stream.investment.service.InvestmentPortfolioProductServiceTest#createBadRequestNotDuplicateExternalIdCases") + void createBadRequestNotDuplicateExternalId_skipsProduct(String scenario, String responseBody) { + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(HttpClientErrorException.create( + HttpStatus.BAD_REQUEST, "Bad Request", null, + responseBody != null ? responseBody.getBytes() : null, null))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("patch fails with WebClientResponseException — falls back to existing product") + void patchFailsWithWebClientResponse_fallsBackToExisting() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing); + + when(investmentRestProductPortfolioService.updatePortfolioProduct(any(), any(), any())) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.CONFLICT.value(), "Conflict", HttpHeaders.EMPTY, + "{\"errors\":{\"external_id\":[\"already exists\"]}}".getBytes(), StandardCharsets.UTF_8))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading")))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("patch fails with unexpected exception — skips product") + void patchFailsWithUnexpectedException_skipsProduct() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing); + + when(investmentRestProductPortfolioService.updatePortfolioProduct(any(), any(), any())) + .thenReturn(Mono.error(new IllegalStateException("patch failed"))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create fails with WebClientResponseException — skips product") + void createFailsWithWebClientResponse_skipsProduct() { + 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(WebClientResponseException.create( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "Server Error", HttpHeaders.EMPTY, null, + StandardCharsets.UTF_8))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), null)))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create fails with HttpClientErrorException body — skips product") + void createFailsWithHttpClientErrorBody_skipsProduct() { + 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(HttpClientErrorException.create( + HttpStatus.BAD_REQUEST, "Bad Request", null, "{\"code\":\"INVALID_INPUT\"}".getBytes(), null))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), null)))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create duplicate externalId (WebClient 400, null body) — skips product") + void createDuplicateExternalId_webClientNullBody_skipsProduct() { + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", HttpHeaders.EMPTY, null, + StandardCharsets.UTF_8))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create duplicate externalId (HttpClient non-400) — skips product") + void createDuplicateExternalId_httpClientNonBadRequest_skipsProduct() { + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(HttpClientErrorException.create( + HttpStatus.CONFLICT, "Conflict", null, + "{\"errors\":{\"external_id\":[\"already exists\"]}}".getBytes(), null))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("create duplicate externalId (WebClient non-400) — skips product") + void createDuplicateExternalId_webClientNonBadRequest_skipsProduct() { + String externalId = "ext-balanced-robo-advisor-004"; + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListByExternalIdReturnsEmpty(externalId, ProductTypeEnum.ROBO_ADVISOR); + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.CONFLICT.value(), "Conflict", HttpHeaders.EMPTY, + "{\"errors\":{\"external_id\":[\"already exists\"]}}".getBytes(), StandardCharsets.UTF_8))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("arrangement portfolio name not found — falls back to lowest order product") + void arrangementPortfolioNameNotFound_fallsBackToLowestOrder() { + UUID lowOrderUuid = UUID.randomUUID(); + UUID highOrderUuid = UUID.randomUUID(); + ProductPortfolio lowOrderTemplate = buildTemplate("Low Order Product", ProductTypeEnum.SELF_TRADING); + ProductPortfolio highOrderTemplate = buildTemplate("High Order Product", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder() + .portfolioProducts(List.of(lowOrderTemplate, highOrderTemplate)) + .build(); + + InvestmentArrangement arrangement = InvestmentArrangement.builder() + .name("Arrangement") + .productTypeExternalId(ProductTypeEnum.SELF_TRADING.getValue()) + .productPortfolioName("Non Matching Name") + .build(); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + PortfolioProduct lowOrder = buildApiProduct(lowOrderUuid, "Low Order Product", ProductTypeEnum.SELF_TRADING, 1); + PortfolioProduct highOrder = buildApiProduct(highOrderUuid, "High Order Product", ProductTypeEnum.SELF_TRADING, 2); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.just(lowOrder), Mono.just(highOrder)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products).hasSize(2)) + .verifyComplete(); + + assertThat(arrangement.getInvestmentProductId()).isEqualTo(lowOrderUuid); + } + + @Test + @DisplayName("name search paginates — finds product on second page") + void nameSearchPaginates_findsProductOnSecondPage() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Balanced", ProductTypeEnum.ROBO_ADVISOR); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Balanced", ProductTypeEnum.ROBO_ADVISOR, 13); + PaginatedPortfolioProductList firstPage = new PaginatedPortfolioProductList() + .count(1) + .next(URI.create("http://next-page")) + .results(List.of(buildApiProduct(UUID.randomUUID(), "Other", ProductTypeEnum.ROBO_ADVISOR, 1))); + PaginatedPortfolioProductList secondPage = new PaginatedPortfolioProductList() + .count(1) + .results(List.of(existing)); + + 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.ROBO_ADVISOR.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(firstPage)); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), + isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), isNull(), eq(ORDERING), + eq(List.of(ProductTypeEnum.ROBO_ADVISOR.getValue())), eq(List.of("default")), isNull())) + .thenReturn(Mono.just(secondPage)); + + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(existing)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Balanced")))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); } @Test @@ -412,4 +829,55 @@ private void stubListReturnsProducts(ProductTypeEnum productType, PortfolioProdu eq(List.of(productType.getValue())), isNull(), isNull())) .thenReturn(Mono.just(page)); } + + private void stubListByExternalIdReturns(String externalId, ProductTypeEnum productType, + PortfolioProduct product) { + PaginatedPortfolioProductList page = new PaginatedPortfolioProductList() + .count(1) + .results(List.of(product)); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), eq(externalId), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq(List.of(productType.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(page)); + } + + private void stubListByExternalIdReturnsEmpty(String externalId, ProductTypeEnum productType) { + PaginatedPortfolioProductList emptyPage = new PaginatedPortfolioProductList() + .count(0) + .results(Collections.emptyList()); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), eq(externalId), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq(List.of(productType.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(emptyPage)); + } + + private void stubListByExternalIdReturnsEmptyThenProduct(String externalId, ProductTypeEnum productType, + PortfolioProduct product) { + PaginatedPortfolioProductList emptyPage = new PaginatedPortfolioProductList() + .count(0) + .results(Collections.emptyList()); + PaginatedPortfolioProductList page = new PaginatedPortfolioProductList() + .count(1) + .results(List.of(product)); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), eq(externalId), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), + eq(List.of(productType.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(emptyPage), Mono.just(page)); + } + + private HttpClientErrorException duplicateExternalIdHttpClientException() { + return HttpClientErrorException.create(HttpStatus.BAD_REQUEST, "Bad Request", null, + "{\"errors\":{\"external_id\":[\"Portfolio Product with this external id already exists.\"]}}".getBytes(), + null); + } + + private WebClientResponseException duplicateExternalIdWebClientException() { + return WebClientResponseException.create(HttpStatus.BAD_REQUEST.value(), "Bad Request", HttpHeaders.EMPTY, + "{\"errors\":{\"external_id\":[\"Portfolio Product with this external id already exists.\"]}}".getBytes(), + StandardCharsets.UTF_8); + } + }