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/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/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..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
@@ -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;
@@ -17,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;
@@ -78,26 +80,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());
}
}
@@ -143,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() {
@@ -181,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() {
@@ -208,7 +256,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 +288,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 +321,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/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 41aff85ae..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
@@ -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,52 @@ void noExistingEntriesAllCreated() throws Exception {
}
@Test
- @DisplayName("existing entries with matching title are skipped (not duplicated)")
- void existingEntriesWithMatchingTitleAreSkipped() {
+ @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 {
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 +362,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;
}