Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# Changelog
All notable changes to this project will be documented in this file.

## [10.6.2]
## [10.7.1]
### Changed
- Updated stream-investment to be able to seed extra_data to investment portfolio
- fix content entry seeding issue

## [10.7.0]
### Changed
- Updated stream-investment to be able to seed to investment caboose

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ public com.backbase.investment.api.service.sync.v1.AssetUniverseApi restAssetUni
@Primary
public InvestmentRestNewsContentService investmentNewsContentService(
@Qualifier("restContentApi") ContentApi restContentApi,
@Qualifier("restInvestmentApiClient") com.backbase.investment.api.service.sync.ApiClient restInvestmentApiClient) {
return new InvestmentRestNewsContentService(restContentApi, restInvestmentApiClient);
@Qualifier("restInvestmentApiClient") com.backbase.investment.api.service.sync.ApiClient restInvestmentApiClient,
@Qualifier("restInvestmentObjectMapper") ObjectMapper restInvestmentObjectMapper) {
return new InvestmentRestNewsContentService(restContentApi, restInvestmentApiClient, restInvestmentObjectMapper);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.Builder;
import lombok.Data;
Expand All @@ -20,6 +21,7 @@ public class InvestmentArrangement {
private String productPortfolioName;
private BigDecimal initialCash;
private BigDecimal withdrawalAmount;
private Map<String, String> extraData;

private UUID investmentProductId;
private List<String> legalEntityIds;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,11 @@
.name(investmentArrangement.getName())
.clients(associatedClients)
.status(StatusA3dEnum.ACTIVE)
.activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths()));
.activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths()))
.extraData(investmentArrangement.getExtraData());

log.debug("Attempting to patch existing portfolio: uuid={}, externalId={}",
uuid, investmentArrangement.getExternalId());
log.debug("Attempting to patch existing portfolio: uuid={}, externalId={}, extraData={}",
uuid, investmentArrangement.getExternalId(), investmentArrangement.getExtraData());

return portfolioApi.patchPortfolio(uuid, null, null, null, patchedPortfolioUpdateRequest)
.doOnSuccess(updated -> {
Expand Down Expand Up @@ -237,7 +238,11 @@
.currency(Optional.ofNullable(investmentArrangement.getCurrency())
.orElse(config.getPortfolio().getDefaultCurrency()))
.status(StatusA3dEnum.ACTIVE)
.activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths()));
.activated(OffsetDateTime.now().minusMonths(config.getPortfolio().getActivationPastMonths()))

Check warning on line 241 in stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Explicitly specify the time zone by passing a ZoneId or a Clock to the .now() method.

See more on https://sonarcloud.io/project/issues?id=com.backbase.stream%3Astream-services&issues=AZ-jXkbDwDoFqFPrF10S&open=AZ-jXkbDwDoFqFPrF10S&pullRequest=617
.extraData(investmentArrangement.getExtraData());

log.debug("Creating investment portfolio: externalId={}, name={}, extraData={}",
investmentArrangement.getExternalId(), investmentArrangement.getName(), investmentArrangement.getExtraData());

return portfolioApi.createPortfolio(request, null, null, null)
.doOnSuccess(created -> log.info(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.backbase.stream.investment.service.resttemplate;

import static com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdateRequest.JSON_PROPERTY_ASSETS;
import static com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdateRequest.JSON_PROPERTY_THUMBNAIL;
import static com.backbase.stream.investment.service.resttemplate.InvestmentRestAssetUniverseService.getFileNameForLog;

import com.backbase.investment.api.service.sync.ApiClient;
Expand All @@ -11,6 +13,10 @@
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -42,6 +48,12 @@
* <li>Thumbnail attachment for newly created content entries</li>
* </ul>
*
* <p>Content entry create uses JSON {@code POST /service-api/v2/content/entries/} via
* {@link ApiClient#invokeAPI} instead of the generated {@code ContentApi#createContentEntry},
* because investment-service-api 1.6.x rejects explicit {@code thumbnail: null}, requires an
* {@code assets} array (including empty), and multipart create does not reliably transmit JSON
* array fields. Thumbnail upload remains a multipart PATCH.
*
* <p>Design notes (see CODING_RULES_COPILOT.md):
* <ul>
* <li>No direct manipulation of generated API classes beyond construction and mapping</li>
Expand All @@ -56,8 +68,13 @@

/** Maximum number of content or tag entries retrieved in a single list call. */
public static final int CONTENT_RETRIEVE_LIMIT = 100;

private static final String CREATE_CONTENT_ENTRY_PATH = "/service-api/v2/content/entries/";

Check warning on line 72 in stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=com.backbase.stream%3Astream-services&issues=AZ-jXkeGwDoFqFPrF10U&open=AZ-jXkeGwDoFqFPrF10U&pullRequest=617
private static final String[] JSON_CONTENT_TYPES = {"application/json"};

private final ContentApi contentApi;
private final ApiClient apiClient;
private final ObjectMapper objectMapper;
private final ContentMapper contentMapper = Mappers.getMapper(ContentMapper.class);

/**
Expand Down Expand Up @@ -190,7 +207,7 @@
* @return Mono that completes when all eligible entries have been processed
*/
public Mono<Void> upsertContent(List<MarketNewsEntry> contentEntries) {
log.info("Starting content upsert batch operation: totalEntriesSubmitted={}", contentEntries.size());
log.info("Starting content entries upsert batch operation: totalEntriesSubmitted={}", contentEntries.size());
log.debug("Content upsert batch details: entries={}", contentEntries);

return findEntriesNewContent(contentEntries)
Expand Down Expand Up @@ -220,7 +237,7 @@
EntryCreateUpdateRequest createUpdateRequest = contentMapper.map(request);
log.debug("Content entry request mapped: title='{}', request={}", request.getTitle(), createUpdateRequest);

return Mono.defer(() -> Mono.just(contentApi.createContentEntry(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={}",
Expand All @@ -231,6 +248,47 @@
.onErrorResume(error -> Mono.empty());
}

/**
* Creates a content entry via JSON POST.
*
* <p>{@code thumbnail} is omitted (investment rejects explicit null) and {@code assets} is always
* included, even when empty. The payload is serialised to {@code byte[]} because
* {@code RestTemplate} does not reliably serialise {@link ObjectNode} bodies.
*/
private EntryCreateUpdate createContentEntry(EntryCreateUpdateRequest 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 create request", exception);
}

final List<MediaType> accept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES);
final MediaType contentType = apiClient.selectHeaderContentType(JSON_CONTENT_TYPES);
ParameterizedTypeReference<EntryCreateUpdate> returnType = new ParameterizedTypeReference<>() {
};

return apiClient.invokeAPI(
CREATE_CONTENT_ENTRY_PATH,
HttpMethod.POST,
Collections.emptyMap(),
new LinkedMultiValueMap<>(),
bodyBytes,
new HttpHeaders(),
new LinkedMultiValueMap<>(),
new LinkedMultiValueMap<>(),
accept,
contentType,
new String[]{},
returnType)
.getBody();
}

/**
* 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.
Expand Down Expand Up @@ -296,10 +354,9 @@
MultiValueMap<String, String> localVarCookieParams = new LinkedMultiValueMap<>();
MultiValueMap<String, Object> localVarFormParams = new LinkedMultiValueMap<>();

localVarFormParams.add("thumbnail", thumbnail);
localVarFormParams.add(JSON_PROPERTY_THUMBNAIL, thumbnail);

final String[] localVarAccepts = {"application/json"};
final List<MediaType> localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
final List<MediaType> localVarAccept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES);
final String[] localVarContentTypes = {"multipart/form-data"};
final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ void restAssetUniverseApi_returnsAssetUniverseApi() {
@Test
@DisplayName("investmentNewsContentService returns non-null InvestmentRestNewsContentService")
void investmentNewsContentService_returnsNewsContentService() {
assertThat(config.investmentNewsContentService(mock(ContentApi.class), syncApiClient))
assertThat(config.investmentNewsContentService(
mock(ContentApi.class), syncApiClient, config.restInvestmentObjectMapper(new ObjectMapper())))
.isNotNull().isInstanceOf(InvestmentRestNewsContentService.class);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.backbase.stream.investment.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
Expand All @@ -15,12 +16,14 @@
import com.backbase.investment.api.service.v1.PortfolioTradingAccountsApi;
import com.backbase.investment.api.service.v1.model.Deposit;
import com.backbase.investment.api.service.v1.model.DepositRequest;
import com.backbase.investment.api.service.v1.model.IntegrationPortfolioCreateRequest;
import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalCreate;
import com.backbase.investment.api.service.v1.model.IntegrationWithdrawalList;
import com.backbase.investment.api.service.v1.model.PaginatedDepositList;
import com.backbase.investment.api.service.v1.model.PaginatedIntegrationWithdrawalListList;
import com.backbase.investment.api.service.v1.model.PaginatedPortfolioListList;
import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList;
import com.backbase.investment.api.service.v1.model.PatchedPortfolioUpdateRequest;
import com.backbase.investment.api.service.v1.model.PortfolioList;
import com.backbase.investment.api.service.v1.model.PortfolioProduct;
import com.backbase.investment.api.service.v1.model.PortfolioTradingAccount;
Expand All @@ -42,6 +45,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -599,6 +603,115 @@
verify(portfolioApi, never()).patchPortfolio(any(), any(), any(), any(), any());
}

@Test
@DisplayName("create portfolio — forwards arrangement extraData on create request")
void upsertInvestmentPortfolios_create_forwardsExtraData() {
UUID portfolioUuid = UUID.randomUUID();
UUID productId = UUID.randomUUID();
String externalId = "PORTFOLIO-EXT-EXTRA-CREATE";
String leExternalId = "LE-EXTRA-CREATE";
UUID clientUuid = UUID.randomUUID();
Map<String, String> extraData = Map.of("riskLevel", "Low", "ignoreMarketHoursForTest", "false");

InvestmentArrangement arrangement = buildArrangement(externalId, "Extra Create Portfolio", productId,
leExternalId);
when(arrangement.getExtraData()).thenReturn(extraData);

PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class);

Check warning on line 620 in stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a static import for "mock".

See more on https://sonarcloud.io/project/issues?id=com.backbase.stream%3Astream-services&issues=AZ-jXkfrwDoFqFPrF10X&open=AZ-jXkfrwDoFqFPrF10X&pullRequest=617
when(emptyList.getResults()).thenReturn(List.of());
when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(),
isNull(), eq(externalId), isNull(), isNull(), eq(1),
isNull(), isNull(), isNull(), isNull()))
.thenReturn(Mono.just(emptyList));

PortfolioList created = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6));
when(portfolioApi.createPortfolio(any(), isNull(), isNull(), isNull()))
.thenReturn(Mono.just(created));

StepVerifier.create(service.upsertInvestmentPortfolios(arrangement,
Map.of(leExternalId, List.of(clientUuid))))
.expectNextMatches(p -> portfolioUuid.equals(p.getUuid()))
.verifyComplete();

ArgumentCaptor<IntegrationPortfolioCreateRequest> requestCaptor =
ArgumentCaptor.forClass(IntegrationPortfolioCreateRequest.class);
verify(portfolioApi).createPortfolio(requestCaptor.capture(), isNull(), isNull(), isNull());
assertThat(requestCaptor.getValue().getExtraData()).isEqualTo(extraData);
}

@Test
@DisplayName("patch portfolio — forwards arrangement extraData on patch request")
void upsertInvestmentPortfolios_patch_forwardsExtraData() {
UUID portfolioUuid = UUID.randomUUID();
UUID productId = UUID.randomUUID();
String externalId = "PORTFOLIO-EXT-EXTRA-PATCH";
String leExternalId = "LE-EXTRA-PATCH";
UUID clientUuid = UUID.randomUUID();
Map<String, String> extraData = Map.of("riskLevel", "High");

InvestmentArrangement arrangement = buildArrangement(externalId, "Extra Patch Portfolio", productId,
leExternalId);
when(arrangement.getExtraData()).thenReturn(extraData);

PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6));
PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6));

PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class);

Check warning on line 659 in stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a static import for "mock".

See more on https://sonarcloud.io/project/issues?id=com.backbase.stream%3Astream-services&issues=AZ-jXkfrwDoFqFPrF10Y&open=AZ-jXkfrwDoFqFPrF10Y&pullRequest=617
when(paginatedList.getResults()).thenReturn(List.of(existing));
when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(),
isNull(), eq(externalId), isNull(), isNull(), eq(1),
isNull(), isNull(), isNull(), isNull()))
.thenReturn(Mono.just(paginatedList));
when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any()))
.thenReturn(Mono.just(patched));

StepVerifier.create(service.upsertInvestmentPortfolios(arrangement,
Map.of(leExternalId, List.of(clientUuid))))
.expectNextMatches(p -> portfolioUuid.equals(p.getUuid()))
.verifyComplete();

ArgumentCaptor<PatchedPortfolioUpdateRequest> requestCaptor =
ArgumentCaptor.forClass(PatchedPortfolioUpdateRequest.class);
verify(portfolioApi).patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(),
requestCaptor.capture());
assertThat(requestCaptor.getValue().getExtraData()).isEqualTo(extraData);
}

@Test
@DisplayName("create portfolio — leaves extraData null when arrangement has none")
void upsertInvestmentPortfolios_create_nullExtraDataWhenAbsent() {
UUID portfolioUuid = UUID.randomUUID();
UUID productId = UUID.randomUUID();
String externalId = "PORTFOLIO-EXT-NO-EXTRA";
String leExternalId = "LE-NO-EXTRA";
UUID clientUuid = UUID.randomUUID();

InvestmentArrangement arrangement = buildArrangement(externalId, "No Extra Portfolio", productId,
leExternalId);
when(arrangement.getExtraData()).thenReturn(null);

PaginatedPortfolioListList emptyList = Mockito.mock(PaginatedPortfolioListList.class);

Check warning on line 693 in stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a static import for "mock".

See more on https://sonarcloud.io/project/issues?id=com.backbase.stream%3Astream-services&issues=AZ-jXkfrwDoFqFPrF10Z&open=AZ-jXkfrwDoFqFPrF10Z&pullRequest=617
when(emptyList.getResults()).thenReturn(List.of());
when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(),
isNull(), eq(externalId), isNull(), isNull(), eq(1),
isNull(), isNull(), isNull(), isNull()))
.thenReturn(Mono.just(emptyList));

PortfolioList created = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6));
when(portfolioApi.createPortfolio(any(), isNull(), isNull(), isNull()))
.thenReturn(Mono.just(created));

StepVerifier.create(service.upsertInvestmentPortfolios(arrangement,
Map.of(leExternalId, List.of(clientUuid))))
.expectNextMatches(p -> portfolioUuid.equals(p.getUuid()))
.verifyComplete();

ArgumentCaptor<IntegrationPortfolioCreateRequest> requestCaptor =
ArgumentCaptor.forClass(IntegrationPortfolioCreateRequest.class);
verify(portfolioApi).createPortfolio(requestCaptor.capture(), isNull(), isNull(), isNull());
assertThat(requestCaptor.getValue().getExtraData()).isNull();
}

@Test
@DisplayName("patch fails with WebClientResponseException — falls back to existing portfolio")
void upsertInvestmentPortfolios_patchFails_withWebClientException_fallsBackToExisting() {
Expand Down
Loading
Loading