From c1af9f4f4d7c04d82bee9bddbc50e09154ba8c5a Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Tue, 6 Oct 2026 18:56:58 +0530 Subject: [PATCH 1/2] Add external_id to the model portfolio, documents, news entries --- stream-investment/investment-core/pom.xml | 2 +- .../stream/investment/ModelPortfolio.java | 6 + .../stream/investment/ProductPortfolio.java | 3 +- .../model/ContentDocumentEntry.java | 2 + .../investment/model/MarketNewsEntry.java | 2 + .../InvestmentModelPortfolioService.java | 94 +++++++--- .../InvestmentPortfolioProductService.java | 31 ++-- .../InvestmentRiskQuestionaryService.java | 85 +++++---- .../service/resttemplate/ContentMapper.java | 5 + .../InvestmentRestDocumentContentService.java | 31 +++- .../InvestmentRestNewsContentService.java | 173 +++++++++++++----- ...InvestmentRestProductPortfolioService.java | 4 +- .../investment/saga/InvestmentSagaTest.java | 8 + ...InvestmentPortfolioProductServiceTest.java | 43 ++++- .../InvestmentRiskQuestionaryServiceTest.java | 35 +++- .../InvestmentRestNewsContentServiceTest.java | 37 +++- ...stmentRestProductPortfolioServiceTest.java | 6 +- 17 files changed, 407 insertions(+), 160 deletions(-) diff --git a/stream-investment/investment-core/pom.xml b/stream-investment/investment-core/pom.xml index 89980827b..6a93fd0f3 100644 --- a/stream-investment/investment-core/pom.xml +++ b/stream-investment/investment-core/pom.xml @@ -15,7 +15,7 @@ true - 1.16.0 + 1.16.2 diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ModelPortfolio.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ModelPortfolio.java index ae53e815d..2042a66f9 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ModelPortfolio.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ModelPortfolio.java @@ -1,6 +1,7 @@ package com.backbase.stream.investment; import com.backbase.investment.api.service.v1.model.ProductTypeEnum; +import com.fasterxml.jackson.annotation.JsonAlias; import java.util.List; import java.util.Map; import java.util.UUID; @@ -12,11 +13,16 @@ public class ModelPortfolio { private UUID uuid; + @JsonAlias("external_id") + private String externalId; private String arrangementExternalId; private ProductTypeEnum productTypeEnum; private String name; + @JsonAlias("cash_weight") private double cashWeight; + @JsonAlias("risk_level") private int riskLevel; + @JsonAlias("allocation") private List allocations; private UUID createdFor; private Map extraData; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java index 0a358fa31..b7d2ce0f3 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java @@ -7,7 +7,6 @@ import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY; import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE; -import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.PortfolioProductBadge; import com.backbase.investment.api.service.v1.model.PortfolioProductStatusEnum; import com.backbase.investment.api.service.v1.model.ProductTypeEnum; @@ -46,7 +45,7 @@ public class ProductPortfolio { @JsonProperty(JSON_PROPERTY_ADVICE_ENGINE) private String adviceEngine; @JsonProperty(JSON_PROPERTY_MODEL_PORTFOLIO) - private InvestorModelPortfolio modelPortfolio; + private ModelPortfolio modelPortfolio; @JsonProperty(JSON_PROPERTY_PRODUCT_TYPE) private ProductTypeEnum productType; @JsonProperty(JSON_PROPERTY_EXTRA_DATA) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/ContentDocumentEntry.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/ContentDocumentEntry.java index b55218451..3fb9ee920 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/ContentDocumentEntry.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/ContentDocumentEntry.java @@ -18,6 +18,8 @@ @Builder public class ContentDocumentEntry { + @JsonProperty("external_id") + private String externalId; private String name; private String description; private List tags; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/MarketNewsEntry.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/MarketNewsEntry.java index 155b7903d..03bdbf033 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/MarketNewsEntry.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/MarketNewsEntry.java @@ -18,6 +18,8 @@ @AllArgsConstructor public class MarketNewsEntry { + @JsonProperty("external_id") + private String externalId; private String title; private String excerpt; private String body; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java index 02fc9df76..6d8cbef59 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java @@ -18,6 +18,7 @@ import lombok.extern.slf4j.Slf4j; import org.mapstruct.factory.Mappers; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -52,22 +53,22 @@ public class InvestmentModelPortfolioService { public Flux upsertModels(InvestmentData investmentData) { return Flux.fromIterable(Objects.requireNonNullElse(investmentData.getModelPortfolios(), List.of())) - .flatMap(modelPortfolioTemplate -> upsertModelPortfolio(modelPortfolioTemplate) - .doOnSuccess(modelPortfolio -> modelPortfolioTemplate.uuid(modelPortfolio.getUuid()))); + .flatMap(modelPortfolioTemplate -> upsertModelPortfolioResponse(modelPortfolioTemplate) + .doOnSuccess(response -> modelPortfolioTemplate.uuid(response.getUuid()))); } - public Mono upsertModelPortfolio(InvestorModelPortfolio modelPortfolio) { + public Mono upsertModelPortfolio(ModelPortfolio modelPortfolio) { Objects.requireNonNull(modelPortfolio, "ModelPortfolio must not be null"); - ModelPortfolio map = modelPortfolioMapper.map(modelPortfolio); - return upsertModelPortfolio(map) - .map(mp -> { - map.uuid(mp.getUuid()); - return map; + return upsertModelPortfolioResponse(modelPortfolio) + .map(response -> { + modelPortfolio.uuid(response.getUuid()); + return modelPortfolio; }) .onErrorResume(WebClientResponseException.class, ex -> { - log.warn("Continuing without portfolio model: name={}, riskLevel={}, status={}", - map.getName(), map.getRiskLevel(), ex.getStatusCode()); + log.warn("Continuing without portfolio model: externalId={}, name={}, riskLevel={}, status={}", + modelPortfolio.getExternalId(), modelPortfolio.getName(), modelPortfolio.getRiskLevel(), + ex.getStatusCode()); return Mono.empty(); }); } @@ -77,7 +78,7 @@ public Mono upsertModelPortfolio(InvestorModelPortfolio modelPor * *

This method implements an upsert pattern: *

    - *
  1. Searches for existing model portfolios by name and risk level
  2. + *
  3. Searches for existing model portfolios by {@code external_id}, then by name
  4. *
  5. If found, patches the existing model portfolio
  6. *
  7. If not found, creates a new model portfolio
  8. *
@@ -86,30 +87,77 @@ public Mono upsertModelPortfolio(InvestorModelPortfolio modelPor * @return Mono emitting the created or updated model portfolio response * @throws NullPointerException if modelPortfolio is null */ - private Mono upsertModelPortfolio(ModelPortfolio modelPortfolio) { + private Mono upsertModelPortfolioResponse(ModelPortfolio modelPortfolio) { Objects.requireNonNull(modelPortfolio, "ModelPortfolio must not be null"); String modelName = modelPortfolio.getName(); Integer riskLevel = modelPortfolio.getRiskLevel(); - log.debug("Upserting model portfolio: name={}, riskLevel={}", modelName, riskLevel); + log.debug("Upserting model portfolio: externalId={}, name={}, riskLevel={}", + modelPortfolio.getExternalId(), modelName, riskLevel); - return listExistingModelPortfolios(modelName, riskLevel) - .flatMap(pm -> { - if (isTargetAssetWeightCorrect(pm)) { + return findExistingModelPortfolio(modelPortfolio) + .flatMap(existing -> { + if (existing.expanded() != null && isTargetAssetWeightCorrect(existing.expanded())) { + InvestorModelPortfolio pm = existing.expanded(); modelPortfolio.setAllocations(modelPortfolioMapper.mapAssetModel(pm.getAllocation())); modelPortfolio.setCashWeight(pm.getCashWeight()); - } else { + } else if (existing.expanded() != null) { log.error( "Stored model target asset weight and cash weight are incorrect for uuid={}, name={}, riskLevel={}", - pm.getUuid(), pm.getName(), pm.getRiskLevel()); + existing.uuid(), modelName, riskLevel); } - return patchModelPortfolio(pm.getUuid(), modelPortfolio); + return patchModelPortfolio(existing.uuid(), modelPortfolio); }) .switchIfEmpty(Mono.defer(() -> createNewModelPortfolio(modelPortfolio))) .doOnSuccess(upserted -> log.info( - "Successfully upserted model portfolio: uuid={}, name={}, riskLevel={}", - upserted.getUuid(), upserted.getName(), upserted.getRiskLevel())); + "Successfully upserted model portfolio: uuid={}, externalId={}, name={}, riskLevel={}", + upserted.getUuid(), upserted.getExternalId(), upserted.getName(), upserted.getRiskLevel())); + } + + private record ExistingModelRef(UUID uuid, InvestorModelPortfolio expanded) { + } + + private Mono findExistingModelPortfolio(ModelPortfolio modelPortfolio) { + if (StringUtils.hasText(modelPortfolio.getExternalId())) { + return findExistingByExternalId(modelPortfolio.getExternalId()) + .switchIfEmpty(Mono.defer(() -> listExistingModelPortfolios(modelPortfolio.getName(), + modelPortfolio.getRiskLevel()) + .map(inv -> new ExistingModelRef(inv.getUuid(), inv)))); + } + return listExistingModelPortfolios(modelPortfolio.getName(), modelPortfolio.getRiskLevel()) + .map(inv -> new ExistingModelRef(inv.getUuid(), inv)); + } + + private Mono findExistingByExternalId(String externalId) { + // Do not expand allocation.asset here: expanded assets are objects in JSON but the + // generated OAS list type expects UUID references, which causes DecodingException. + return financialAdviceApi.listModelPortfolio( + null, null, null, + config.getPortfolio().getListModelPageSize(), null, null, null, null, null, null) + .doOnError(throwable -> log.error("Failed to list model portfolios for externalId={}", externalId, + throwable)) + .onErrorResume(throwable -> { + log.warn("Falling back to name-based model lookup after list-by-external-id failed: externalId={}", + externalId); + return Mono.empty(); + }) + .flatMap(page -> { + List results = page != null + ? Objects.requireNonNullElse(page.getResults(), List.of()) + : List.of(); + Optional match = results.stream() + .filter(model -> externalId.equals(model.getExternalId())) + .findFirst(); + if (match.isEmpty()) { + log.info("No existing model portfolio found for externalId={}", externalId); + return Mono.empty(); + } + OASModelPortfolioResponse model = match.get(); + log.info("Found existing model portfolio by externalId: externalId={}, uuid={}, name={}", + externalId, model.getUuid(), model.getName()); + return Mono.just(new ExistingModelRef(model.getUuid(), null)); + }); } private boolean isTargetAssetWeightCorrect(InvestorModelPortfolio pm) { @@ -163,8 +211,8 @@ private Mono listExistingModelPortfolios(String name, In * @return Mono emitting the newly created model portfolio */ private Mono createNewModelPortfolio(ModelPortfolio modelPortfolio) { - log.info("Creating new model portfolio: name={}, riskLevel={}", - modelPortfolio.getName(), modelPortfolio.getRiskLevel()); + log.info("Creating new model portfolio: externalId={}, name={}, riskLevel={}", + modelPortfolio.getExternalId(), modelPortfolio.getName(), modelPortfolio.getRiskLevel()); return investmentRestModelPortfolioService.createModelPortfolio(modelPortfolio) .doOnError(throwable -> logModelPortfolioError("create", modelPortfolio.getName(), modelPortfolio.getRiskLevel(), throwable)); 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 84bb02896..d3905c3f3 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 @@ -1,7 +1,6 @@ package com.backbase.stream.investment.service; 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; @@ -11,7 +10,6 @@ import com.backbase.stream.investment.ModelPortfolio; import com.backbase.stream.investment.ProductPortfolio; import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService; -import com.backbase.stream.investment.service.resttemplate.RestTemplateModelPortfolioMapper; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -21,7 +19,6 @@ import java.util.concurrent.atomic.AtomicInteger; 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; @@ -56,8 +53,6 @@ public class InvestmentPortfolioProductService { private final InvestmentModelPortfolioService modelPortfolioService; private final InvestmentRestProductPortfolioService investmentRestProductPortfolioService; private final InvestmentPortfolioProductDocumentService investmentPortfolioProductDocumentService; - private final RestTemplateModelPortfolioMapper modelPortfolioMapper = - Mappers.getMapper(RestTemplateModelPortfolioMapper.class); public InvestmentPortfolioProductService(InvestmentProductsApi productsApi, IngestConfigProperties config, InvestmentModelPortfolioService modelPortfolioService, @@ -102,7 +97,7 @@ public Mono> upsertInvestmentProducts(InvestmentData inve "Successfully upserted portfolio product: uuid={}, name={}, engine={}, productType={}, model={}", product.getUuid(), product.getName(), product.getAdviceEngine(), product.getProductType(), Optional.ofNullable(product.getModelPortfolio()) - .map(InvestorModelPortfolio::getName).orElse(""))) + .map(mp -> mp.getName()).orElse(""))) .doOnError(throwable -> log.error("Failed to upsert portfolio product: name={}, productType={}", p.getName(), p.getProductType(), throwable)) .onErrorResume(throwable -> { @@ -133,8 +128,7 @@ private Mono> enrichPortfolioProductsWithModels(Investmen Mono modelPortfolio = upsertPortfolioModel(pp); return modelPortfolio .map(mp -> { - InvestorModelPortfolio mappedModelPortfolio = modelPortfolioMapper.map(mp); - pp.setModelPortfolio(mappedModelPortfolio); + pp.setModelPortfolio(mp); return pp; }) .switchIfEmpty(Mono.just(pp)); @@ -143,7 +137,7 @@ private Mono> enrichPortfolioProductsWithModels(Investmen } private Mono upsertPortfolioModel(ProductPortfolio pp) { - InvestorModelPortfolio modelPortfolio = pp.getModelPortfolio(); + ModelPortfolio modelPortfolio = pp.getModelPortfolio(); if (modelPortfolio == null) { return Mono.empty(); } @@ -159,7 +153,7 @@ private void assignProductsToArrangements(List products, + "productType={}, model={}, arrangementName={}", product.getUuid(), product.getName(), product.getAdviceEngine(), product.getProductType(), Optional.ofNullable(product.getModelPortfolio()) - .map(InvestorModelPortfolio::getName).orElse(""), + .map(mp -> mp.getName()).orElse(""), arrangement.getName()); arrangement.setInvestmentProductId(product.getUuid()); })); @@ -189,16 +183,27 @@ private Optional findMatchingProduct(List pr return typeProducts.stream().min(BY_ORDER); } + /** + * De-duplicates ingest templates before upsert. Uses {@code external_id} when present so rows with the same + * display name but different stable ids are all processed; otherwise de-duplicates by name (last wins). + */ private Collection distinctProducts(List products) { return products.stream() .collect(Collectors.toMap( - ProductPortfolio::getName, + this::distinctProductKey, pp -> pp, (existing, replacement) -> replacement )) .values(); } + private String distinctProductKey(ProductPortfolio portfolioProduct) { + if (StringUtils.hasText(portfolioProduct.getExternalId())) { + return portfolioProduct.getExternalId(); + } + return portfolioProduct.getName(); + } + private Mono listExistingPortfolioProducts(ProductPortfolio portfolioProduct) { ProductTypeEnum productType = portfolioProduct.getProductType(); @@ -226,7 +231,7 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po private Mono listExistingPortfolioProductsByName(ProductPortfolio portfolioProduct) { Integer riskLevel = Optional.ofNullable(portfolioProduct.getModelPortfolio()) - .map(InvestorModelPortfolio::getRiskLevel).orElse(null); + .map(ModelPortfolio::getRiskLevel).orElse(null); ProductTypeEnum productType = portfolioProduct.getProductType(); String productCategory = portfolioProduct.getProductCategory(); @@ -329,7 +334,7 @@ private Mono createPortfolioProductWithModel(ProductPortfolio String productType = portfolioProduct.getProductType().getValue(); UUID modelPortfolioUuid = Optional.ofNullable(portfolioProduct.getModelPortfolio()) - .map(InvestorModelPortfolio::getUuid).orElse(null); + .map(ModelPortfolio::getUuid).orElse(null); log.info("Creating portfolio product: name={}, productType={}, modelPortfolioUuid={}", portfolioProduct.getName(), productType, modelPortfolioUuid); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryService.java index cdc89c49c..cfe152a33 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryService.java @@ -12,9 +12,12 @@ import com.backbase.stream.investment.model.RiskQuestion; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; @@ -82,12 +85,16 @@ private List retrieveAllQuestions(List rqs) { public Mono> upsertRiskQuestionsChoices(List rqs) { List riskChoices = retrieveAllQuestions(rqs); - return Flux.fromIterable(riskChoices) - .flatMap(this::upsertRiskChoice) - .collectList() - .doOnSuccess(choices -> log.debug( - "Successfully upserted question choices count: {}", choices.size())) - .map(r -> rqs) + if (riskChoices.isEmpty()) { + return Mono.just(rqs); + } + return listExistingRiskChoicesByKey() + .flatMap(existingByKey -> Flux.fromIterable(riskChoices) + .flatMap(choice -> upsertRiskChoice(choice, existingByKey)) + .collectList() + .doOnSuccess(choices -> log.debug( + "Successfully upserted question choices count: {}", choices.size())) + .map(ignored -> rqs)) .onErrorResume(WebClientResponseException.class, e -> { log.error("Error upsert question choices: {}", e.getResponseBodyAsString()); return Mono.just(rqs); @@ -127,21 +134,26 @@ private Mono upsertRiskQuestion(RiskQuestion question) { code, order, throwable)); } - private Mono upsertRiskChoice(BaseRiskChoice choice) { + private Mono upsertRiskChoice(BaseRiskChoice choice, Map existingByKey) { Objects.requireNonNull(choice, "Risk question choice must not be null"); String code = choice.getCode(); Integer order = choice.getOrder(); + String questionCode = choice.getQuestion(); - log.info("Upserting risk choice: question={}, code={}, order={}", choice.getQuestion(), code, order); + log.info("Upserting risk choice: question={}, code={}, order={}", questionCode, code, order); - return listExistingRiskChoices(choice.getQuestion(), code) - .flatMap(existing -> patchRiskChoice(existing.getUuid(), choice)) - .switchIfEmpty(Mono.defer(() -> createNewRiskChoice(choice))) - .map(o -> choice) + RiskChoice existing = existingByKey.get(choiceLookupKey(questionCode, code)); + Mono upsert = existing != null + ? patchRiskChoice(existing.getUuid(), choice) + : createNewRiskChoice(choice); + + return upsert + .map(ignored -> choice) .doOnSuccess(upserted -> log.info( "Successfully upserted risk choice: uuid={}, question={}, code={}, order={}", - upserted.getUuid(), upserted.getQuestion(), upserted.getCode(), upserted.getOrder())) + existing != null ? existing.getUuid() : null, upserted.getQuestion(), upserted.getCode(), + upserted.getOrder())) .doOnError(throwable -> log.error( "Failed to upsert risk choice: code={}, order={}", code, order, throwable)); @@ -208,35 +220,30 @@ private Mono listExistingRiskQuestions(String code) { .filter(Objects::nonNull); } - private Mono listExistingRiskChoices(String question, String code) { - return riskAssessmentApi.listRiskChoices(100, null) - .doOnSuccess(choices -> log.debug( - "List risk choices query completed: question={}, code={}, found={} total results", - question, code, choices != null ? choices.getResults().size() : 0)) - .doOnError(throwable -> log.error( - "Failed to list existing risk choices: question={}, code={}", - question, code, throwable)) - .flatMap(choices -> { - if (Objects.isNull(choices) || CollectionUtils.isEmpty(choices.getResults())) { - log.info("No existing risk choice found with question={}, code={}", question, code); - return Mono.empty(); - } - - // Filter by code - RiskChoice matchingQuestion = choices.getResults().stream() - .filter(q -> code.equals(q.getCode()) && question.equals(q.getQuestion().getCode())) - .findFirst() - .orElse(null); - - if (matchingQuestion == null) { - log.info("No existing risk choice found with question={}, code={}", question, code); + /** Lists all risk choices once (paginated) keyed by question code and choice code. */ + private Mono> listExistingRiskChoicesByKey() { + int pageSize = ingestProperties.getAssessment().getRiskQuestionsPageSize(); + AtomicInteger offset = new AtomicInteger(0); + return riskAssessmentApi.listRiskChoices(pageSize, offset.get()) + .doOnError(throwable -> log.error("Failed to list existing risk choices", throwable)) + .expand(response -> { + if (response.getNext() == null) { return Mono.empty(); } + return riskAssessmentApi.listRiskChoices(pageSize, offset.addAndGet(pageSize)) + .doOnError(throwable -> log.error( + "Failed to list existing risk choices at offset={}", offset.get(), throwable)); + }) + .flatMapIterable(response -> response.getResults() != null ? response.getResults() : List.of()) + .filter(choice -> choice.getQuestion() != null && choice.getCode() != null) + .collect(Collectors.toMap( + choice -> choiceLookupKey(choice.getQuestion().getCode(), choice.getCode()), + Function.identity(), + (first, second) -> first)); + } - log.info("Found existing risk choice: uuid={}, question={}, code={}", - matchingQuestion.getUuid(), question, code); - return Mono.just(matchingQuestion); - }); + private static String choiceLookupKey(String questionCode, String choiceCode) { + return questionCode + "::" + choiceCode; } /** diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/ContentMapper.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/ContentMapper.java index 717c82b0f..9d81c980f 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/ContentMapper.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/ContentMapper.java @@ -2,6 +2,7 @@ import com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdateRequest; import com.backbase.investment.api.service.sync.v1.model.OASDocumentRequestDataRequest; +import com.backbase.investment.api.service.sync.v1.model.PatchedEntryCreateUpdateRequest; import com.backbase.stream.investment.AssetKey; import com.backbase.stream.investment.ModelAsset; import com.backbase.stream.investment.model.ContentDocumentEntry; @@ -21,6 +22,10 @@ public interface ContentMapper { @Mapping(target = "status", constant = "PUBLISHED") EntryCreateUpdateRequest map(MarketNewsEntry entry); + @Mapping(target = "thumbnail", ignore = true) + @Mapping(target = "status", constant = "PUBLISHED") + PatchedEntryCreateUpdateRequest mapPatch(MarketNewsEntry entry); + @Mapping(target = "assets", source = "assets", qualifiedByName = "mapRawAsserts") OASDocumentRequestDataRequest map(ContentDocumentEntry request); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java index 46cf63227..4b7117a07 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java @@ -28,6 +28,7 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import reactor.core.publisher.Flux; @@ -194,27 +195,39 @@ private Mono insertDocument(ContentDocumentEntry request) private Flux> findUpsertDocuments( List documents) { - List existsNews = contentApi.listContentDocuments(null, CONTENT_RETRIEVE_LIMIT, null, 0, - null, null) + List existingDocuments = contentApi.listContentDocuments(null, CONTENT_RETRIEVE_LIMIT, + null, 0, null, null) .getResults().stream().filter(Objects::nonNull).toList(); - if (existsNews.isEmpty()) { - log.info("No existing document found in system: newEntries={}", - documents.size()); + if (existingDocuments.isEmpty()) { + log.info("No existing document found in system: newEntries={}", documents.size()); return Flux.fromIterable(documents.stream() .map(UpsertPartition::createPartition) .toList()); } - Map existTitles = existsNews.stream() + Map existByExternalId = existingDocuments.stream() + .filter(doc -> StringUtils.hasText(doc.getExternalId())) + .collect(Collectors.toMap(OASDocumentResponse::getExternalId, OASDocumentResponse::getUuid, + (existing, replacement) -> existing)); + Map existByName = existingDocuments.stream() .collect(Collectors.toMap(OASDocumentResponse::getName, OASDocumentResponse::getUuid, (existing, replacement) -> existing)); - List> newEntries = documents.stream() - .map(d -> new UpsertPartition<>(existTitles.get(d.getName()), d)) + List> partitions = documents.stream() + .map(document -> { + UUID existingUuid = null; + if (StringUtils.hasText(document.getExternalId())) { + existingUuid = existByExternalId.get(document.getExternalId()); + } + if (existingUuid == null) { + existingUuid = existByName.get(document.getName()); + } + return new UpsertPartition<>(existingUuid, document); + }) .toList(); - return Flux.fromIterable(newEntries); + return Flux.fromIterable(partitions); } public OASDocumentResponse createContentDocument(OASDocumentRequestDataRequest data, Resource document) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentService.java index 4703ec0f2..98dddb650 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentService.java @@ -10,9 +10,11 @@ import com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdate; import com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdateRequest; import com.backbase.investment.api.service.sync.v1.model.EntryTagRequest; +import com.backbase.investment.api.service.sync.v1.model.PatchedEntryCreateUpdateRequest; import com.backbase.investment.api.service.sync.v1.model.PatchedEntryTagRequest; import com.backbase.stream.investment.model.MarketNewsEntry; import com.backbase.stream.investment.model.ContentTag; +import com.backbase.stream.investment.model.UpsertPartition; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -21,9 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.UUID; -import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +35,7 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -44,7 +45,7 @@ *

This service manages: *

    *
  • Market news tag creation and updates
  • - *
  • Market news content entry creation (skips duplicates by title)
  • + *
  • Market news content entry create and update (matched by {@code external_id}, then title)
  • *
  • Thumbnail attachment for newly created content entries
  • *
* @@ -70,6 +71,7 @@ public class InvestmentRestNewsContentService { public static final int CONTENT_RETRIEVE_LIMIT = 100; private static final String CREATE_CONTENT_ENTRY_PATH = "/service-api/v2/content/entries/"; + private static final String PATCH_CONTENT_ENTRY_PATH = "/service-api/v2/content/entries/{uuid}/"; private static final String[] JSON_CONTENT_TYPES = {"application/json"}; private final ContentApi contentApi; @@ -200,22 +202,18 @@ private Mono patchTagEntry(ContentTag contentTag) { } /** - * Creates new market news content entries. Entries whose title matches an existing entry are skipped. - * Individual failures are logged and swallowed so remaining entries continue processing. - * - * @param contentEntries list of content entries to create - * @return Mono that completes when all eligible entries have been processed + * Creates or updates market news content entries matched by {@code external_id}, then by title. */ public Mono upsertContent(List contentEntries) { log.info("Starting content entries upsert batch operation: totalEntriesSubmitted={}", contentEntries.size()); log.debug("Content upsert batch details: entries={}", contentEntries); - return findEntriesNewContent(contentEntries) + return findUpsertEntries(contentEntries) .flatMap(this::upsertSingleEntry) .count() - .doOnNext(entriesCreated -> log.info( - "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesCreated={}", - contentEntries.size(), entriesCreated)) + .doOnNext(entriesProcessed -> log.info( + "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesProcessed={}", + contentEntries.size(), entriesProcessed)) .doOnError(error -> log.error( "Content upsert batch failed: totalEntriesSubmitted={}, errorType={}, errorMessage={}", contentEntries.size(), error.getClass().getSimpleName(), error.getMessage(), error)) @@ -223,28 +221,58 @@ public Mono upsertContent(List contentEntries) { } /** - * Creates a single market news content entry and optionally attaches a thumbnail. - * Callers must supply entries that have already been filtered as non-duplicates. - * Errors are logged and swallowed to allow processing of remaining entries. + * Creates or patches a single market news content entry from an upsert partition produced by + * {@link #findUpsertEntries(List)}. + * + *

When {@link UpsertPartition#id()} is {@code null}, delegates to {@link #createSingleEntry(MarketNewsEntry)}; + * otherwise patches the entry at that UUID via {@link #patchSingleEntry(UUID, MarketNewsEntry)}. Both paths + * optionally attach a thumbnail. Failures are logged and swallowed in those delegates so batch processing + * continues. * - * @param request the content entry to create - * @return Mono that completes with the created entry, or empty if creation fails + * @param partition existing entry UUID (when matched by {@code external_id} or title) and payload to upsert + * @return Mono that completes with the created or patched entry, or empty if the operation fails */ - private Mono upsertSingleEntry(MarketNewsEntry request) { - log.debug("Creating content entry: title='{}', hasThumbnail={}", request.getTitle(), - request.getThumbnailResource() != null); + private Mono upsertSingleEntry(UpsertPartition partition) { + MarketNewsEntry request = partition.entity(); + if (partition.id() == null) { + return createSingleEntry(request); + } + return patchSingleEntry(partition.id(), request); + } + + private Mono createSingleEntry(MarketNewsEntry request) { + log.debug("Creating content entry: externalId={}, title='{}', hasThumbnail={}", + request.getExternalId(), request.getTitle(), request.getThumbnailResource() != null); EntryCreateUpdateRequest createUpdateRequest = contentMapper.map(request); - log.debug("Content entry request mapped: title='{}', request={}", request.getTitle(), createUpdateRequest); return Mono.defer(() -> Mono.fromCallable(() -> createContentEntry(createUpdateRequest))) .flatMap(entry -> addThumbnail(entry, request.getThumbnailResource())) .doOnSuccess(created -> log.info( - "Content entry created successfully: title='{}', uuid={}, thumbnailAttached={}", - request.getTitle(), created.getUuid(), request.getThumbnailResource() != null)) + "Content entry created successfully: externalId={}, title='{}', uuid={}", + request.getExternalId(), request.getTitle(), created.getUuid())) .doOnError(error -> log.error( - "Content entry creation failed: title='{}', errorType={}, errorMessage={}", - request.getTitle(), error.getClass().getSimpleName(), error.getMessage(), error)) + "Content entry creation failed: externalId={}, title='{}', errorType={}, errorMessage={}", + request.getExternalId(), request.getTitle(), error.getClass().getSimpleName(), error.getMessage(), + error)) + .onErrorResume(error -> Mono.empty()); + } + + private Mono patchSingleEntry(UUID uuid, MarketNewsEntry request) { + log.debug("Patching content entry: uuid={}, externalId={}, title='{}'", + uuid, request.getExternalId(), request.getTitle()); + + PatchedEntryCreateUpdateRequest patchRequest = contentMapper.mapPatch(request); + + return Mono.defer(() -> Mono.fromCallable(() -> patchContentEntry(uuid, patchRequest))) + .flatMap(entry -> addThumbnail(entry, request.getThumbnailResource())) + .doOnSuccess(patched -> log.info( + "Content entry patched successfully: externalId={}, title='{}', uuid={}", + request.getExternalId(), request.getTitle(), patched.getUuid())) + .doOnError(error -> log.error( + "Content entry patch failed: uuid={}, externalId={}, title='{}', errorType={}, errorMessage={}", + uuid, request.getExternalId(), request.getTitle(), error.getClass().getSimpleName(), + error.getMessage(), error)) .onErrorResume(error -> Mono.empty()); } @@ -290,40 +318,85 @@ private EntryCreateUpdate createContentEntry(EntryCreateUpdateRequest request) { } /** - * Filters the supplied content entries to those not already present in the system. - * An entry is considered a duplicate when its title contains an existing entry title. + * Patches a content entry via JSON PATCH. * - * @param contentEntries entries to filter - * @return Flux of entries that should be created + *

Uses the same payload rules as {@link #createContentEntry}: omit {@code thumbnail} (investment rejects + * explicit null) and always send an {@code assets} array. */ - private Flux findEntriesNewContent(List contentEntries) { - Map entryByTitle = contentEntries.stream() - .collect(Collectors.toMap(MarketNewsEntry::getTitle, Function.identity())); - log.debug("Filtering content entries: requestedTitles={}", entryByTitle.keySet()); + private EntryCreateUpdate patchContentEntry(UUID uuid, PatchedEntryCreateUpdateRequest request) { + ObjectNode requestBody = objectMapper.valueToTree(request); + requestBody.remove(JSON_PROPERTY_THUMBNAIL); + requestBody.set(JSON_PROPERTY_ASSETS, objectMapper.valueToTree( + Objects.requireNonNullElse(request.getAssets(), List.of()))); + + final byte[] bodyBytes; + try { + bodyBytes = objectMapper.writeValueAsBytes(requestBody); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Failed to serialize content entry patch request", exception); + } - List existingNews = contentApi.listContentEntries(null, CONTENT_RETRIEVE_LIMIT, 0, null, null, null, null) + final Map uriVariables = new HashMap<>(); + uriVariables.put("uuid", uuid.toString()); + + final List accept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES); + final MediaType contentType = apiClient.selectHeaderContentType(JSON_CONTENT_TYPES); + ParameterizedTypeReference returnType = new ParameterizedTypeReference<>() { + }; + + return apiClient.invokeAPI( + PATCH_CONTENT_ENTRY_PATH, + HttpMethod.PATCH, + uriVariables, + new LinkedMultiValueMap<>(), + bodyBytes, + new HttpHeaders(), + new LinkedMultiValueMap<>(), + new LinkedMultiValueMap<>(), + accept, + contentType, + new String[]{}, + returnType) + .getBody(); + } + + private Flux> findUpsertEntries(List contentEntries) { + List existingEntries = contentApi.listContentEntries(null, CONTENT_RETRIEVE_LIMIT, 0, null, null, null, + null) .getResults().stream().filter(Objects::nonNull).toList(); - if (existingNews.isEmpty()) { - log.info( - "No existing content found in system: totalEntriesSubmitted={}, existingEntries=0, entriesToCreate={}", - entryByTitle.size(), entryByTitle.size()); - return Flux.fromIterable(entryByTitle.values()); + if (existingEntries.isEmpty()) { + log.info("No existing content entries in system: entriesToCreate={}", contentEntries.size()); + return Flux.fromIterable(contentEntries.stream() + .map(UpsertPartition::createPartition) + .toList()); } - Set existingTitles = existingNews.stream().map(Entry::getTitle).collect(Collectors.toSet()); - List newEntries = contentEntries.stream() - .filter(entry -> existingTitles.stream().noneMatch(existingTitle -> entry.getTitle().contains(existingTitle))) + Map byExternalId = existingEntries.stream() + .filter(entry -> StringUtils.hasText(entry.getExternalId())) + .collect(Collectors.toMap(Entry::getExternalId, Entry::getUuid, (existing, replacement) -> existing)); + Map byTitle = existingEntries.stream() + .collect(Collectors.toMap(Entry::getTitle, Entry::getUuid, (existing, replacement) -> existing)); + + List> partitions = contentEntries.stream() + .map(entry -> { + UUID existingUuid = null; + if (StringUtils.hasText(entry.getExternalId())) { + existingUuid = byExternalId.get(entry.getExternalId()); + } + if (existingUuid == null) { + existingUuid = byTitle.get(entry.getTitle()); + } + return new UpsertPartition<>(existingUuid, entry); + }) .toList(); - log.info( - "Content filtering completed: totalEntriesSubmitted={}, existingEntriesFound={}, " - + "entriesToCreate={}, duplicatesSkipped={}", - entryByTitle.size(), existingNews.size(), newEntries.size(), entryByTitle.size() - newEntries.size()); - log.debug("Filtered new content titles: titles={}", - newEntries.stream().map(MarketNewsEntry::getTitle).collect(Collectors.toList())); + log.info("Content entry upsert plan: submitted={}, existingInService={}, toCreate={}, toPatch={}", + contentEntries.size(), existingEntries.size(), + partitions.stream().filter(p -> p.id() == null).count(), + partitions.stream().filter(p -> p.id() != null).count()); - return Flux.fromIterable(newEntries); + return Flux.fromIterable(partitions); } /** @@ -364,7 +437,7 @@ private Mono addThumbnail(EntryCreateUpdate entry, Resource t ParameterizedTypeReference localReturnType = new ParameterizedTypeReference<>() { }; - apiClient.invokeAPI("/service-api/v2/content/entries/{uuid}/", HttpMethod.PATCH, uriVariables, + apiClient.invokeAPI(PATCH_CONTENT_ENTRY_PATH, HttpMethod.PATCH, uriVariables, localVarQueryParams, null, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localReturnType); 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 ac37fca13..4a4b89ff8 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 @@ -16,9 +16,9 @@ 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; import com.backbase.investment.api.service.v1.model.PortfolioProduct; import com.backbase.stream.configuration.IngestConfigProperties; +import com.backbase.stream.investment.ModelPortfolio; import com.backbase.stream.investment.TranslationsSupport; import com.backbase.stream.investment.ProductPortfolio; import java.util.Collections; @@ -190,7 +190,7 @@ private PortfolioProduct invokeUpdate(String uuid, List expand, ProductP .ifPresent(v -> formParams.add(JSON_PROPERTY_ADVICE_ENGINE, v)); Optional.ofNullable(data.getModelPortfolio()) .ifPresent(v -> formParams.add(JSON_PROPERTY_MODEL_PORTFOLIO, - Optional.of(v).map(InvestorModelPortfolio::getUuid).map(UUID::toString).orElse(null))); + Optional.of(v).map(ModelPortfolio::getUuid).map(UUID::toString).orElse(null))); Optional.ofNullable(data.getProductType()) .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_TYPE, v.getValue())); Optional.ofNullable(data.getProductCategory()) 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 666e97a17..722878f60 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 @@ -703,6 +703,8 @@ void upsertDeposits_depositFailure_continuesAndCompletes() { .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertDeposits(any())) .thenReturn(Mono.error(new RuntimeException("deposit failed"))); + when(investmentPortfolioService.upsertWithdrawals(any())) + .thenReturn(Mono.empty()); when(asyncTaskService.checkPriceAsyncTasksFinished(any())) .thenReturn(Mono.empty()); when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) @@ -736,6 +738,8 @@ void upsertDepositsAndAllocations_error_marksTaskFailed() { .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertDeposits(any())) .thenReturn(Mono.just(new Deposit())); + when(investmentPortfolioService.upsertWithdrawals(any())) + .thenReturn(Mono.empty()); when(investmentPortfolioAllocationService.createDepositAllocation(any())) .thenReturn(Mono.just(new Deposit())); when(asyncTaskService.checkPriceAsyncTasksFinished(any())) @@ -849,6 +853,8 @@ private void stubAllServicesSuccess() { .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertDeposits(any())) .thenReturn(Mono.just(new Deposit())); + when(investmentPortfolioService.upsertWithdrawals(any())) + .thenReturn(Mono.empty()); when(investmentPortfolioAllocationService.createDepositAllocation(any())) .thenReturn(Mono.just(new Deposit())); when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) @@ -874,6 +880,8 @@ private void wireTrivialPipelineAfterModelPortfolios() { .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) .thenReturn(Mono.just(List.of())); + when(investmentPortfolioService.upsertWithdrawals(any())) + .thenReturn(Mono.empty()); 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/InvestmentPortfolioProductServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java index bc442587b..9b8928ddc 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 @@ -10,7 +10,6 @@ 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; @@ -230,6 +229,37 @@ void duplicateTemplatesByName_processesOnce() { assertThat(arr2.getInvestmentProductId()).isEqualTo(productUuid); } + @Test + @DisplayName("same name but different externalId — processes both products") + void sameNameDifferentExternalId_processesBoth() { + UUID firstUuid = UUID.randomUUID(); + UUID secondUuid = UUID.randomUUID(); + ProductPortfolio first = buildTemplate("Shared Name", ProductTypeEnum.SELF_TRADING); + first.setExternalId("ext-product-a"); + ProductPortfolio second = buildTemplate("Shared Name", ProductTypeEnum.SELF_TRADING); + second.setExternalId("ext-product-b"); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(first, second)).build(); + + stubListByExternalIdReturnsEmpty("ext-product-a", ProductTypeEnum.SELF_TRADING); + stubListByExternalIdReturnsEmpty("ext-product-b", ProductTypeEnum.SELF_TRADING); + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenAnswer(invocation -> { + ProductPortfolio template = invocation.getArgument(0); + if ("ext-product-a".equals(template.getExternalId())) { + return Mono.just(buildApiProduct(firstUuid, "Shared Name", ProductTypeEnum.SELF_TRADING, 1)); + } + return Mono.just(buildApiProduct(secondUuid, "Shared Name", ProductTypeEnum.SELF_TRADING, 2)); + }); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of())) + .assertNext(products -> assertThat(products).hasSize(2)) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, times(2)).createPortfolioProduct(any(), any()); + } + @Test @DisplayName("arrangement with productPortfolioName — matches product by name") void arrangementWithPortfolioName_matchesByName() { @@ -266,10 +296,10 @@ void arrangementWithPortfolioName_matchesByName() { void templateWithModelPortfolio_upsertsModelFirst() { UUID modelUuid = UUID.randomUUID(); UUID productUuid = UUID.randomUUID(); - InvestorModelPortfolio investorModel = new InvestorModelPortfolio( - null, "Growth Model", 0.25, 7, null, null, null); + ModelPortfolio modelTemplate = ModelPortfolio.builder() + .name("Growth Model").cashWeight(0.25).riskLevel(7).externalId("ext-mp-growth-test").build(); ProductPortfolio template = buildTemplate("Robo Product", ProductTypeEnum.ROBO_ADVISOR); - template.setModelPortfolio(investorModel); + template.setModelPortfolio(modelTemplate); template.setProductCategory("retail"); InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); @@ -278,7 +308,8 @@ void templateWithModelPortfolio_upsertsModelFirst() { ModelPortfolio upsertedModel = ModelPortfolio.builder() .uuid(modelUuid).name("Growth Model").riskLevel(7).cashWeight(0.25).build(); - when(modelPortfolioService.upsertModelPortfolio(investorModel)).thenReturn(Mono.just(upsertedModel)); + when(modelPortfolioService.upsertModelPortfolio(eq(modelTemplate))) + .thenReturn(Mono.just(upsertedModel)); stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); PortfolioProduct created = buildApiProduct(productUuid, "Robo Product", ProductTypeEnum.ROBO_ADVISOR, 1); @@ -289,7 +320,7 @@ void templateWithModelPortfolio_upsertsModelFirst() { .assertNext(products -> assertThat(products).hasSize(1)) .verifyComplete(); - verify(modelPortfolioService).upsertModelPortfolio(investorModel); + verify(modelPortfolioService).upsertModelPortfolio(eq(modelTemplate)); ArgumentCaptor templateCaptor = ArgumentCaptor.forClass(ProductPortfolio.class); verify(investmentRestProductPortfolioService).createPortfolioProduct( templateCaptor.capture(), eq(List.of(ALLOCATION_ASSET_EXPAND))); diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java index 0a1daff44..b25bba2ed 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,26 +79,22 @@ class EmptyInputTests { @Test @DisplayName("should return empty list and skip API calls when question list is empty") void emptyList_returnsEmptyListAndSkipsApiCalls() { - // Choices phase also needs listRiskChoices stub for empty list - when(riskAssessmentApi.listRiskChoices(any(), any())) - .thenReturn(Mono.just(new PaginatedRiskChoiceList().results(List.of()))); - StepVerifier.create(service.upsertRiskQuestions(List.of())) .assertNext(result -> assertThat(result).isEmpty()) .verifyComplete(); verify(riskAssessmentApi, never()).listRiskQuestions(any(), any()); + verify(riskAssessmentApi, never()).listRiskChoices(any(), any()); } @Test @DisplayName("should treat null question list as empty") void nullList_treatedAsEmpty() { - when(riskAssessmentApi.listRiskChoices(any(), any())) - .thenReturn(Mono.just(new PaginatedRiskChoiceList().results(List.of()))); - StepVerifier.create(service.upsertRiskQuestions(null)) .assertNext(result -> assertThat(result).isEmpty()) .verifyComplete(); + + verify(riskAssessmentApi, never()).listRiskChoices(any(), any()); } } @@ -208,7 +205,7 @@ void existingChoice_patchesExisting() { BaseRiskQuestion questionRef = new BaseRiskQuestion().code("Q1"); // Use @JsonCreator constructor: (uuid, created, updated) RiskChoice existing = new RiskChoice(choiceUuid, null, null) - .code("C1") + .code("C_Q1") .question(questionRef); PaginatedRiskChoiceList page = new PaginatedRiskChoiceList().results(List.of(existing)); @@ -240,6 +237,26 @@ void choiceApiError_recoversAndReturnsInput() { .assertNext(result -> assertThat(result).hasSize(1)) .verifyComplete(); } + + @Test + @DisplayName("should list risk choices only once when upserting multiple choices") + void multipleChoices_listsRiskChoicesOnce() { + RiskQuestion question1 = buildQuestion("Q1", 1); + RiskQuestion question2 = buildQuestion("Q2", 2); + PaginatedRiskChoiceList emptyPage = new PaginatedRiskChoiceList().results(List.of()); + + when(riskAssessmentApi.listRiskChoices(any(), any())) + .thenReturn(Mono.just(emptyPage)); + when(riskAssessmentApi.createRiskChoice(any())) + .thenReturn(Mono.just(new OASBaseRiskChoice().code("C1"))); + + StepVerifier.create(service.upsertRiskQuestionsChoices(List.of(question1, question2))) + .assertNext(result -> assertThat(result).hasSize(2)) + .verifyComplete(); + + verify(riskAssessmentApi, times(1)).listRiskChoices(any(), any()); + verify(riskAssessmentApi, times(2)).createRiskChoice(any()); + } } // ========================================================================= @@ -253,7 +270,7 @@ private RiskQuestion buildQuestion(String code, int order) { q.setDescription("Test question " + code); q.setScore(5.0); QuestionChoice choice = new QuestionChoice(); - choice.setCode("C1"); + choice.setCode("C_" + code); choice.setOrder(1); choice.setDescription("Choice 1"); choice.setScore(3.0); diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java index 41aff85ae..48c8ef653 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java @@ -92,6 +92,24 @@ private void stubJsonContentEntryCreate(EntryCreateUpdate created) { any())).thenReturn(ResponseEntity.ok(created)); } + private void stubJsonContentEntryPatch(UUID uuid, EntryCreateUpdate patched) { + when(apiClient.selectHeaderAccept(any())).thenReturn(List.of(MediaType.APPLICATION_JSON)); + when(apiClient.selectHeaderContentType(new String[]{"application/json"})).thenReturn(MediaType.APPLICATION_JSON); + when(apiClient.invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), + eq(HttpMethod.PATCH), + eq(Map.of("uuid", uuid.toString())), + any(), + jsonBodyCaptor.capture(), + any(), + any(), + any(), + any(), + eq(MediaType.APPLICATION_JSON), + any(), + any())).thenReturn(ResponseEntity.ok(patched)); + } + private void stubMultipartContentEntryPatch(UUID uuid, EntryCreateUpdate patched) { when(apiClient.selectHeaderAccept(any())).thenReturn(List.of(MediaType.APPLICATION_JSON)); when(apiClient.selectHeaderContentType(new String[]{"multipart/form-data"})) @@ -291,17 +309,22 @@ void noExistingEntriesAllCreated() throws Exception { } @Test - @DisplayName("existing entries with matching title are skipped (not duplicated)") - void existingEntriesWithMatchingTitleAreSkipped() { + @DisplayName("existing entries with matching title are patched via JSON without thumbnail field") + void existingEntriesWithMatchingTitleArePatched() throws Exception { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("Existing News"); + entry.setExternalId("ext-article-existing"); - Entry existingEntry = new Entry(UUID.randomUUID(), "Existing News", + UUID existingUuid = UUID.randomUUID(); + Entry existingEntry = new Entry(existingUuid, "Existing News", null, null, null, null, null, null, null, null); PaginatedEntryList page = new PaginatedEntryList().results(List.of(existingEntry)); + EntryCreateUpdate patched = new EntryCreateUpdate(existingUuid, null, null); + patched.setTitle("Existing News"); when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(page); + stubJsonContentEntryPatch(existingUuid, patched); StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); @@ -309,6 +332,14 @@ void existingEntriesWithMatchingTitleAreSkipped() { verify(apiClient, never()).invokeAPI( eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), any(), any(), any(), any(), any(), + any(), any(), eq(MediaType.APPLICATION_JSON), any(), any()); + + byte[] bodyBytes = (byte[]) jsonBodyCaptor.getValue(); + JsonNode body = objectMapper.readTree(bodyBytes); + assertThat(body.has(JSON_PROPERTY_THUMBNAIL)).isFalse(); + assertThat(body.get(JSON_PROPERTY_ASSETS).isArray()).isTrue(); } @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 index 44c8023d4..842e73068 100644 --- 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 @@ -19,11 +19,11 @@ 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.ModelPortfolio; import com.backbase.stream.investment.ProductPortfolio; import java.util.List; import java.util.Map; @@ -383,8 +383,8 @@ private ProductPortfolio buildFullTemplate(UUID modelPortfolioUuid) { 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)); + template.setModelPortfolio(ModelPortfolio.builder() + .uuid(modelPortfolioUuid).name("Growth Model").cashWeight(0.25).riskLevel(7).build()); return template; } From 7cc9662eb68f1fd817703f91d44644d5e50d0416 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Wed, 7 Oct 2026 13:00:56 +0530 Subject: [PATCH 2/2] Add external_id to the model portfolio, documents, news entries - sonar --- CHANGELOG.md | 4 + .../InvestmentModelPortfolioServiceTest.java | 184 ++++++++++++++++++ .../InvestmentRiskQuestionaryServiceTest.java | 51 +++++ ...estmentRestDocumentContentServiceTest.java | 44 +++++ .../InvestmentRestNewsContentServiceTest.java | 30 +++ 5 files changed, 313 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b28fa1915..35405231c 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.16.0] +### Changed +- Add externalId to the model portfolio, documents, news entries in investment + ## [10.15.0] ### Changed - Fix for Investment Portfolio products create failing due to uniques validation 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 0ce6031b7..a5976d2f7 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 @@ -13,6 +13,7 @@ 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; @@ -380,6 +381,182 @@ void createNew_genericException_propagatesError() { } } + // ========================================================================= + // externalId lookup + // ========================================================================= + + @Nested + @DisplayName("externalId lookup") + class ExternalIdLookupTests { + + @Test + @DisplayName("should patch by uuid when existing model is found by externalId") + void externalIdFound_patchesWithoutNameList() { + String externalId = "ext-model-balanced-001"; + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); + + OASModelPortfolioResponse listed = buildResponse(existingUuid, "Server Name", 5); + listed.setExternalId(externalId); + PaginatedOASModelPortfolioResponseList page = new PaginatedOASModelPortfolioResponseList() + .results(List.of(listed)); + stubListModelPortfolioReturns(page); + + OASModelPortfolioResponse patched = buildResponse(existingUuid, "Balanced", 5); + when(investmentRestModelPortfolioService.patchModelPortfolio( + eq(existingUuid.toString()), any(ModelPortfolio.class))) + .thenReturn(Mono.just(patched)); + + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(financialAdviceApi, never()).listModelPortfolioWithResponseSpec( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + verify(investmentRestModelPortfolioService, never()).createModelPortfolio(any()); + } + + @Test + @DisplayName("should fall back to name search when externalId is not in list results") + void externalIdNotFound_fallsBackToNameSearch() { + String externalId = "ext-model-missing"; + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Moderate", 4, 0.3); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); + + PaginatedOASModelPortfolioResponseList emptyMatch = new PaginatedOASModelPortfolioResponseList() + .results(List.of()); + stubListModelPortfolioReturns(emptyMatch); + stubListReturnsOne("Moderate", 4, 0.3, existingUuid); + + when(investmentRestModelPortfolioService.patchModelPortfolio( + eq(existingUuid.toString()), any(ModelPortfolio.class))) + .thenReturn(Mono.just(buildResponse(existingUuid, "Moderate", 4))); + + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(financialAdviceApi).listModelPortfolioWithResponseSpec( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE), + eq("Moderate"), isNull(), isNull(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("should fall back to name search when list-by-external-id API fails") + void externalIdListFails_fallsBackToNameSearch() { + String externalId = "ext-model-list-error"; + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); + + when(financialAdviceApi.listModelPortfolio( + isNull(), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("list failed"))); + stubListReturnsOne("Income", 2, 0.4, existingUuid); + + when(investmentRestModelPortfolioService.patchModelPortfolio( + eq(existingUuid.toString()), any(ModelPortfolio.class))) + .thenReturn(Mono.just(buildResponse(existingUuid, "Income", 2))); + + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("should create when externalId lookup empty and name search finds nothing") + void externalIdEmptyAndNameNotFound_createsNew() { + String externalId = "ext-model-new"; + UUID newUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Growth", 7, 0.25); + template.setExternalId(externalId); + InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); + + stubListModelPortfolioReturns(new PaginatedOASModelPortfolioResponseList().results(List.of())); + stubListReturnsEmpty("Growth"); + + when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) + .thenReturn(Mono.just(buildResponse(newUuid, "Growth", 7))); + + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(newUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("should still patch when expanded portfolio has incorrect target weights") + void incorrectStoredWeights_stillPatches() { + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Dynamic", 8, 0.1); + InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); + + AssetModelPortfolio badAllocation = new AssetModelPortfolio().weight(0.3); + InvestorModelPortfolio existing = new InvestorModelPortfolio( + existingUuid, "Dynamic", 0.5, 8, List.of(badAllocation), null, null); + PaginatedExpandedModelPortfolioList page = PaginatedExpandedModelPortfolioList.builder() + .count(1) + .results(List.of(existing)) + .build(); + stubListReturns(page, "Dynamic"); + + when(investmentRestModelPortfolioService.patchModelPortfolio( + eq(existingUuid.toString()), any(ModelPortfolio.class))) + .thenReturn(Mono.just(buildResponse(existingUuid, "Dynamic", 8))); + + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertModelPortfolio + // ========================================================================= + + @Nested + @DisplayName("upsertModelPortfolio") + class UpsertModelPortfolioTests { + + @Test + @DisplayName("should return model portfolio with uuid set on success") + void success_setsUuidOnModelPortfolio() { + UUID expectedUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Stable", 4, 0.3); + + stubListReturnsEmpty("Stable"); + when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) + .thenReturn(Mono.just(buildResponse(expectedUuid, "Stable", 4))); + + StepVerifier.create(service.upsertModelPortfolio(template)) + .assertNext(result -> { + assertThat(result.getUuid()).isEqualTo(expectedUuid); + assertThat(result.getName()).isEqualTo("Stable"); + }) + .verifyComplete(); + } + + @Test + @DisplayName("should complete empty when WebClientResponseException occurs") + void webClientError_completesEmpty() { + ModelPortfolio template = buildModelPortfolio("Stable", 4, 0.3); + + stubListReturnsEmpty("Stable"); + WebClientResponseException ex = WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", null, null, null); + when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) + .thenReturn(Mono.error(ex)); + + StepVerifier.create(service.upsertModelPortfolio(template)) + .verifyComplete(); + } + } + // ========================================================================= // patchModelPortfolio // ========================================================================= @@ -523,4 +700,11 @@ private void stubListReturns(PaginatedExpandedModelPortfolioList page, String na when(responseSpec.bodyToMono(PaginatedExpandedModelPortfolioList.class)) .thenReturn(Mono.just(page)); } + + private void stubListModelPortfolioReturns(PaginatedOASModelPortfolioResponseList page) { + when(financialAdviceApi.listModelPortfolio( + isNull(), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull())) + .thenReturn(Mono.just(page)); + } } diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java index b25bba2ed..e8fccc9aa 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRiskQuestionaryServiceTest.java @@ -18,6 +18,7 @@ import com.backbase.stream.configuration.IngestConfigProperties; import com.backbase.stream.investment.model.QuestionChoice; import com.backbase.stream.investment.model.RiskQuestion; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; @@ -140,6 +141,41 @@ void noExistingQuestion_createsNew() { @DisplayName("upsertRiskQuestions – patch path") class PatchPathTests { + @Test + @DisplayName("should find existing question on a later page when listing is paginated") + void existingQuestion_foundOnSecondPage() { + UUID existingUuid = UUID.randomUUID(); + RiskQuestion question = buildQuestion("Q2", 2); + + OASRiskQuestion other = new OASRiskQuestion(UUID.randomUUID(), null, null); + other.setCode("Q_OTHER"); + OASRiskQuestion existing = new OASRiskQuestion(existingUuid, null, null); + existing.setCode("Q2"); + + PaginatedOASRiskQuestionList firstPage = new PaginatedOASRiskQuestionList() + .results(List.of(other)) + .next(URI.create("http://next")); + PaginatedOASRiskQuestionList secondPage = new PaginatedOASRiskQuestionList() + .results(List.of(existing)); + PaginatedRiskChoiceList emptyChoicePage = new PaginatedRiskChoiceList().results(List.of()); + + when(riskAssessmentApi.listRiskQuestions(any(), any())) + .thenReturn(Mono.just(firstPage), Mono.just(secondPage)); + when(riskAssessmentApi.patchRiskQuestion(eq(existingUuid), any())) + .thenReturn(Mono.just(new BaseRiskQuestion().code("Q2"))); + when(riskAssessmentApi.listRiskChoices(any(), any())) + .thenReturn(Mono.just(emptyChoicePage)); + when(riskAssessmentApi.createRiskChoice(any())) + .thenReturn(Mono.just(new OASBaseRiskChoice().code("C1"))); + + StepVerifier.create(service.upsertRiskQuestions(List.of(question))) + .assertNext(result -> assertThat(result).hasSize(1)) + .verifyComplete(); + + verify(riskAssessmentApi).patchRiskQuestion(eq(existingUuid), any()); + verify(riskAssessmentApi, never()).createRiskQuestion(any()); + } + @Test @DisplayName("should patch the existing question when one is found with matching code") void existingQuestion_patchesExisting() { @@ -178,6 +214,21 @@ void existingQuestion_patchesExisting() { @DisplayName("upsertRiskQuestionsChoices – choice upsert") class ChoiceUpsertTests { + @Test + @DisplayName("should return input when question has no choices") + void questionWithoutChoices_skipsChoiceUpsert() { + RiskQuestion question = new RiskQuestion(); + question.setCode("Q_EMPTY"); + question.setOrder(1); + question.setChoices(List.of()); + + StepVerifier.create(service.upsertRiskQuestionsChoices(List.of(question))) + .assertNext(result -> assertThat(result).hasSize(1)) + .verifyComplete(); + + verify(riskAssessmentApi, never()).listRiskChoices(any(), any()); + } + @Test @DisplayName("should create choice when none exists for the question") void noExistingChoice_createsNew() { diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java index 3012feb14..9e73949e3 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java @@ -217,6 +217,31 @@ void noExistingDocumentsAllInserted() { .verifyComplete(); } + @Test + @DisplayName("existing document by externalId triggers patch when name differs") + void existingDocumentByExternalIdTriggersPatch() { + UUID existingUuid = UUID.randomUUID(); + final ContentDocumentEntry doc = ContentDocumentEntry.builder() + .name("Updated Title") + .externalId("ext-doc-001") + .build(); + OASDocumentResponse existingResponse = new OASDocumentResponse(existingUuid); + existingResponse.setName("Original Title"); + existingResponse.setExternalId("ext-doc-001"); + PaginatedOASDocumentResponseList page = new PaginatedOASDocumentResponseList() + .results(List.of(existingResponse)); + OASDocumentResponse patched = new OASDocumentResponse(existingUuid); + patched.setName("Updated Title"); + + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(page); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(patched, HttpStatus.OK)); + + StepVerifier.create(service.upsertDocuments(List.of(doc))) + .verifyComplete(); + } + @Test @DisplayName("existing document by name triggers patch via apiClient") void existingDocumentTriggersPatch() { @@ -238,6 +263,25 @@ void existingDocumentTriggersPatch() { .verifyComplete(); } + @Test + @DisplayName("API failure during document patch is swallowed") + void apiFailureDuringPatchIsSwallowed() { + UUID existingUuid = UUID.randomUUID(); + final ContentDocumentEntry doc = ContentDocumentEntry.builder().name("Existing Doc").build(); + OASDocumentResponse existingResponse = new OASDocumentResponse(existingUuid); + existingResponse.setName("Existing Doc"); + PaginatedOASDocumentResponseList page = new PaginatedOASDocumentResponseList() + .results(List.of(existingResponse)); + + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(page); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenThrow(new RuntimeException("patch failed")); + + StepVerifier.create(service.upsertDocuments(List.of(doc))) + .verifyComplete(); + } + @Test @DisplayName("API failure during document insert is swallowed and processing continues") void apiFailureIsSwallowedAndProcessingContinues() { diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java index 48c8ef653..1060b2051 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java @@ -308,6 +308,36 @@ void noExistingEntriesAllCreated() throws Exception { assertThat(body.get(JSON_PROPERTY_ASSETS)).isEmpty(); } + @Test + @DisplayName("existing entry matched by externalId is patched when title differs") + void existingEntryMatchedByExternalIdPatchesWhenTitleDiffers() throws Exception { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("New Headline"); + entry.setExternalId("ext-news-001"); + + UUID existingUuid = UUID.randomUUID(); + Entry existingEntry = new Entry(existingUuid, "Old Headline", + null, null, null, null, null, null, null, null); + existingEntry.setExternalId("ext-news-001"); + PaginatedEntryList page = new PaginatedEntryList().results(List.of(existingEntry)); + EntryCreateUpdate patched = new EntryCreateUpdate(existingUuid, null, null); + patched.setTitle("New Headline"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(page); + stubJsonContentEntryPatch(existingUuid, patched); + + StepVerifier.create(service.upsertContent(List.of(entry))) + .verifyComplete(); + + verify(apiClient, never()).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), any(), any(), any(), any(), any(), + any(), any(), eq(MediaType.APPLICATION_JSON), any(), any()); + } + @Test @DisplayName("existing entries with matching title are patched via JSON without thumbnail field") void existingEntriesWithMatchingTitleArePatched() throws Exception {