From 4c10705b0f7a112928b57d90fe4f169d47f27b9f Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Fri, 24 Jul 2026 13:32:11 +0530 Subject: [PATCH 1/3] fixed-1 --- ...InvestmentRestServiceApiConfiguration.java | 17 +- .../InvestmentRestNewsContentService.java | 283 +++++++++--------- ...stmentRestServiceApiConfigurationTest.java | 3 +- .../InvestmentRestNewsContentServiceTest.java | 263 ++++++++-------- 4 files changed, 294 insertions(+), 272 deletions(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java index 65d85466b..9e724a636 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java @@ -57,6 +57,14 @@ public com.backbase.investment.api.service.sync.ApiClient restInvestmentApiClien return apiClient; } + /** + * Dedicated {@link ObjectMapper} for investment RestTemplate calls. + * + *

Uses {@link Include#NON_EMPTY} to omit blank optional fields in multipart requests. + * JSON content entry create serialises payloads to {@code byte[]} explicitly in + * {@link InvestmentRestNewsContentService} to avoid {@code RestTemplate} {@code ObjectNode} + * serialisation issues. + */ @Bean @Qualifier("restInvestmentObjectMapper") public ObjectMapper restInvestmentObjectMapper(ObjectMapper legacyObjectMapper) { @@ -80,12 +88,17 @@ public com.backbase.investment.api.service.sync.v1.AssetUniverseApi restAssetUni return new com.backbase.investment.api.service.sync.v1.AssetUniverseApi(restInvestmentApiClient); } + /** + * RestTemplate-based news content service. Requires {@code restInvestmentObjectMapper} for + * manual JSON serialisation in {@link InvestmentRestNewsContentService}. + */ @Bean @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 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 f5a45fe3c..2a7d1478d 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 @@ -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; @@ -9,14 +11,19 @@ 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.PatchedEntryTagRequest; +import com.backbase.investment.api.service.sync.v1.model.RelatedAssetSerializerWithAssetCategoriesRequest; 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; +import java.util.UUID; 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; @@ -29,26 +36,29 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** - * REST client service for upserting market news content and tags via the Investment Content API. + * RestTemplate-based service for market news content and tags against the Investment service + * {@code /service-api/v2/content/} endpoints. * - *

This service manages: + *

This service avoids serialisation issues present in the auto-generated + * {@code ContentApi#createContentEntry} client introduced with investment-service-api 1.6.x: *

* - *

Design notes (see CODING_RULES_COPILOT.md): - *

+ *

Content entry create uses JSON {@code POST /service-api/v2/content/entries/} via + * {@link ApiClient#invokeAPI}; optional thumbnail upload uses multipart + * {@code PATCH /service-api/v2/content/entries/{uuid}/}. Mapping from the stream + * {@link MarketNewsEntry} model is handled by {@link ContentMapper}. + * + * @see InvestmentRestDocumentContentService */ @Slf4j @RequiredArgsConstructor @@ -56,17 +66,23 @@ public class InvestmentRestNewsContentService { /** 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/"; + private static final String PATCH_CONTENT_ENTRY_PATH = "/service-api/v2/content/entries/{uuid}/"; + + private static final String[] JSON_CONTENT_TYPES = {"application/json"}; + private static final String[] MULTIPART_CONTENT_TYPES = {"multipart/form-data"}; + private final ContentApi contentApi; private final ApiClient apiClient; + private final ObjectMapper objectMapper; private final ContentMapper contentMapper = Mappers.getMapper(ContentMapper.class); /** - * Upserts a batch of content tags. For each tag, checks whether a tag with the same code already exists. - * If found, patches it; otherwise creates a new tag. Tags with blank code or value are skipped. - * Individual failures are logged and swallowed so remaining tags continue processing. + * Creates or updates market news tags via {@code ContentApi} entry-tag endpoints. * - * @param tagEntries list of tags to upsert - * @return Mono that completes when all tags have been processed + * @param tagEntries tags to upsert + * @return {@link Mono} that completes when all tags have been processed */ public Mono upsertTags(List tagEntries) { log.info("Starting tag upsert batch operation: totalEntriesSubmitted={}", tagEntries.size()); @@ -85,19 +101,31 @@ public Mono upsertTags(List tagEntries) { } /** - * Upserts a single tag entry using the ContentApi tag endpoints. Implementation follows the upsert pattern: - *

    - *
  1. List existing tag entries to check if the tag code already exists
  2. - *
  3. If tag exists, patch it with the new value
  4. - *
  5. If not found, create a new tag entry
  6. - *
+ * Creates new market news content entries that are not already present in the investment service. * - * @param marketNewsTag the tag to upsert - * @return Mono that completes with the tag when processed, or empty if validation fails or an error occurs + * @param contentEntries news entries to upsert + * @return {@link Mono} that completes when all new entries have been processed */ + public Mono upsertContent(List contentEntries) { + log.info("Starting content upsert batch operation: totalEntriesSubmitted={}", contentEntries.size()); + log.debug("Content upsert batch details: entries={}", contentEntries); + + return findEntriesNewContent(contentEntries) + .flatMap(this::upsertSingleEntry) + .count() + .doOnNext(entriesCreated -> log.info( + "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesCreated={}", + contentEntries.size(), entriesCreated)) + .doOnError(error -> log.error( + "Content upsert batch failed: totalEntriesSubmitted={}, errorType={}, errorMessage={}", + contentEntries.size(), error.getClass().getSimpleName(), error.getMessage(), error)) + .then(); + } + private Mono upsertSingleTag(ContentTag marketNewsTag) { log.debug("Processing tag: code='{}', value='{}'", marketNewsTag.getCode(), marketNewsTag.getValue()); + // Validation if (marketNewsTag.getCode() == null || marketNewsTag.getCode().isBlank()) { log.warn("Skipping tag with empty code: value='{}'", marketNewsTag.getValue()); return Mono.empty(); @@ -111,8 +139,8 @@ private Mono upsertSingleTag(ContentTag marketNewsTag) { log.debug("Checking if tag entry exists: code='{}', value='{}'", marketNewsTag.getCode(), marketNewsTag.getValue()); - return Mono.fromCallable(() -> - contentApi.contentEntryTagList(CONTENT_RETRIEVE_LIMIT, 0)) + // Check if tag entry already exists + return Mono.fromCallable(() -> contentApi.contentEntryTagList(CONTENT_RETRIEVE_LIMIT, 0)) .map(paginatedList -> paginatedList.getResults().stream() .filter(Objects::nonNull) .filter(entry -> marketNewsTag.getCode().equals(entry.getCode())) @@ -138,12 +166,6 @@ private Mono upsertSingleTag(ContentTag marketNewsTag) { }); } - /** - * Creates a new tag entry using the ContentApi. - * - * @param contentTag the tag to create an entry for - * @return Mono of the created tag - */ private Mono createTagEntry(ContentTag contentTag) { EntryTagRequest request = new EntryTagRequest() .code(contentTag.getCode()) @@ -160,12 +182,6 @@ private Mono createTagEntry(ContentTag contentTag) { .thenReturn(contentTag); } - /** - * Patches an existing tag entry with updated values. - * - * @param contentTag the tag with updated values to patch - * @return Mono of the patched tag - */ private Mono patchTagEntry(ContentTag contentTag) { PatchedEntryTagRequest request = new PatchedEntryTagRequest() .code(contentTag.getCode()) @@ -182,46 +198,11 @@ private Mono patchTagEntry(ContentTag contentTag) { .thenReturn(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 - */ - public Mono upsertContent(List contentEntries) { - log.info("Starting content upsert batch operation: totalEntriesSubmitted={}", contentEntries.size()); - log.debug("Content upsert batch details: entries={}", contentEntries); - - return findEntriesNewContent(contentEntries) - .flatMap(this::upsertSingleEntry) - .count() - .doOnNext(entriesCreated -> log.info( - "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesCreated={}", - contentEntries.size(), entriesCreated)) - .doOnError(error -> log.error( - "Content upsert batch failed: totalEntriesSubmitted={}, errorType={}, errorMessage={}", - contentEntries.size(), error.getClass().getSimpleName(), error.getMessage(), error)) - .then(); - } - - /** - * 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. - * - * @param request the content entry to create - * @return Mono that completes with the created entry, or empty if creation fails - */ private Mono upsertSingleEntry(MarketNewsEntry request) { log.debug("Creating content entry: title='{}', hasThumbnail={}", 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.just(contentApi.createContentEntry(createUpdateRequest))) - .flatMap(entry -> addThumbnail(entry, request.getThumbnailResource())) + return Mono.defer(() -> Mono.fromCallable(() -> invokeCreateContentEntry(request))) .doOnSuccess(created -> log.info( "Content entry created successfully: title='{}', uuid={}, thumbnailAttached={}", request.getTitle(), created.getUuid(), request.getThumbnailResource() != null)) @@ -232,12 +213,96 @@ private Mono upsertSingleEntry(MarketNewsEntry 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. + * Creates a content entry and optionally attaches a thumbnail file. + * + * @throws RestClientException if the investment service returns an error + */ + private EntryCreateUpdate invokeCreateContentEntry(MarketNewsEntry entry) throws RestClientException { + EntryCreateUpdateRequest request = contentMapper.map(entry); + EntryCreateUpdate created = invokeCreate(request); + + Resource thumbnail = entry.getThumbnailResource(); + if (thumbnail != null) { + return invokePatchThumbnail(created.getUuid(), thumbnail); + } + return created; + } + + private EntryCreateUpdate invokeCreate(EntryCreateUpdateRequest request) throws RestClientException { + byte[] bodyBytes = buildCreateRequestBodyBytes(request); + log.debug("Creating content entry JSON payload: title='{}'", request.getTitle()); + + final List accept = apiClient.selectHeaderAccept(new String[]{"application/json"}); + final MediaType contentType = apiClient.selectHeaderContentType(JSON_CONTENT_TYPES); + ParameterizedTypeReference 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(); + } + + private EntryCreateUpdate invokePatchThumbnail(UUID uuid, Resource thumbnail) throws RestClientException { + final Map uriVariables = new HashMap<>(); + uriVariables.put("uuid", uuid.toString()); + + final MultiValueMap formParams = new LinkedMultiValueMap<>(); + log.debug("Patching content entry thumbnail: uuid={}, thumbnailFile='{}'", + uuid, getFileNameForLog(thumbnail)); + formParams.add(JSON_PROPERTY_THUMBNAIL, thumbnail); + + final List accept = apiClient.selectHeaderAccept(new String[]{"application/json"}); + final MediaType contentType = apiClient.selectHeaderContentType(MULTIPART_CONTENT_TYPES); + ParameterizedTypeReference returnType = new ParameterizedTypeReference<>() { + }; + + return apiClient.invokeAPI( + PATCH_CONTENT_ENTRY_PATH, + HttpMethod.PATCH, + uriVariables, + new LinkedMultiValueMap<>(), + null, + new HttpHeaders(), + new LinkedMultiValueMap<>(), + formParams, + accept, + contentType, + new String[]{}, + returnType) + .getBody(); + } + + /** + * Builds JSON request bytes for content entry create. * - * @param contentEntries entries to filter - * @return Flux of entries that should be created + *

{@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 byte[] buildCreateRequestBodyBytes(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()))); + + try { + return objectMapper.writeValueAsBytes(requestBody); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Failed to serialize content entry create request", exception); + } + } + private Flux findEntriesNewContent(List contentEntries) { Map entryByTitle = contentEntries.stream() .collect(Collectors.toMap(MarketNewsEntry::getTitle, Function.identity())); @@ -267,62 +332,4 @@ private Flux findEntriesNewContent(List conten return Flux.fromIterable(newEntries); } - - /** - * Attaches a thumbnail to a content entry via multipart PATCH when a thumbnail resource is provided. - * Failures are logged and swallowed so the created entry is retained without a thumbnail. - * - * @param entry the created content entry - * @param thumbnail optional thumbnail resource - * @return Mono emitting the entry (unchanged on success or when attachment is skipped or fails) - */ - private Mono addThumbnail(EntryCreateUpdate entry, Resource thumbnail) { - UUID uuid = entry.getUuid(); - - if (thumbnail == null) { - log.debug("Skipping thumbnail attachment: uuid={}, title='{}'", uuid, entry.getTitle()); - return Mono.just(entry); - } - - log.debug("Attaching thumbnail to content entry: uuid={}, title='{}', thumbnailFile='{}'", uuid, - entry.getTitle(), getFileNameForLog(thumbnail)); - - return Mono.defer(() -> { - Map uriVariables = new HashMap<>(); - uriVariables.put("uuid", uuid); - - MultiValueMap localVarQueryParams = new LinkedMultiValueMap<>(); - HttpHeaders localVarHeaderParams = new HttpHeaders(); - MultiValueMap localVarCookieParams = new LinkedMultiValueMap<>(); - MultiValueMap localVarFormParams = new LinkedMultiValueMap<>(); - - localVarFormParams.add("thumbnail", thumbnail); - - final String[] localVarAccepts = {"application/json"}; - final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); - final String[] localVarContentTypes = {"multipart/form-data"}; - final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); - - String[] localVarAuthNames = new String[]{}; - - ParameterizedTypeReference localReturnType = new ParameterizedTypeReference<>() { - }; - apiClient.invokeAPI("/service-api/v2/content/entries/{uuid}/", HttpMethod.PATCH, uriVariables, - localVarQueryParams, null, localVarHeaderParams, localVarCookieParams, localVarFormParams, - localVarAccept, localVarContentType, localVarAuthNames, localReturnType); - - log.debug("Thumbnail attached successfully: uuid={}, title='{}', thumbnailFile='{}'", uuid, - entry.getTitle(), getFileNameForLog(thumbnail)); - return Mono.just(entry); - }).doOnError(error -> log.error( - "Thumbnail attachment failed: uuid={}, title='{}', thumbnailFile='{}', errorType={}, errorMessage={}", - uuid, entry.getTitle(), getFileNameForLog(thumbnail), error.getClass().getSimpleName(), - error.getMessage(), error)) - .onErrorResume(error -> { - log.warn("Content entry created without thumbnail: uuid={}, title='{}', reason={}", uuid, - entry.getTitle(), error.getMessage()); - return Mono.just(entry); - }); - } - } diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfigurationTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfigurationTest.java index 807c66e04..a2f001f9a 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfigurationTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfigurationTest.java @@ -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); } 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 741575c18..de0d7deb0 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 @@ -1,8 +1,12 @@ 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 org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -18,7 +22,12 @@ import com.backbase.investment.api.service.sync.v1.model.PaginatedEntryTagList; import com.backbase.stream.investment.model.ContentTag; import com.backbase.stream.investment.model.MarketNewsEntry; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; @@ -28,10 +37,23 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; import reactor.test.StepVerifier; +/** + * Unit tests for {@link InvestmentRestNewsContentService}. + * + *

Verifies tag upsert via generated {@code ContentApi} clients and content entry create via + * manual {@code ApiClient} calls (JSON POST with {@code byte[]} body, optional multipart thumbnail PATCH). + */ @ExtendWith(MockitoExtension.class) class InvestmentRestNewsContentServiceTest { @@ -41,18 +63,65 @@ class InvestmentRestNewsContentServiceTest { @Mock private ApiClient apiClient; + @Captor + private ArgumentCaptor jsonBodyCaptor; + + @Captor + private ArgumentCaptor> formParamsCaptor; + + private ObjectMapper objectMapper; private InvestmentRestNewsContentService service; @BeforeEach void setUp() { - service = new InvestmentRestNewsContentService(contentApi, apiClient); + objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + service = new InvestmentRestNewsContentService(contentApi, apiClient, objectMapper); + } + + private void stubJsonContentEntryCreate(EntryCreateUpdate created) { + 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/"), + eq(HttpMethod.POST), + any(), + any(), + jsonBodyCaptor.capture(), + any(), + any(), + any(), + any(), + eq(MediaType.APPLICATION_JSON), + any(), + any())).thenReturn(ResponseEntity.ok(created)); + } + + 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"})) + .thenReturn(MediaType.MULTIPART_FORM_DATA); + when(apiClient.invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), + eq(HttpMethod.PATCH), + eq(Map.of("uuid", uuid.toString())), + any(), + isNull(), + any(), + any(), + formParamsCaptor.capture(), + any(), + eq(MediaType.MULTIPART_FORM_DATA), + any(), + any())).thenReturn(ResponseEntity.ok(patched)); } static Stream invalidTags() { return Stream.of( - org.junit.jupiter.params.provider.Arguments.of(" ", "someValue"), // blank code - org.junit.jupiter.params.provider.Arguments.of("code1", null), // null value - org.junit.jupiter.params.provider.Arguments.of("code1", " ") // blank value + org.junit.jupiter.params.provider.Arguments.of(" ", "someValue"), + org.junit.jupiter.params.provider.Arguments.of("code1", null), + org.junit.jupiter.params.provider.Arguments.of("code1", " ") ); } @@ -82,12 +151,9 @@ void tagWithNullCodeIsSkipped() { .verifyComplete(); verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); - verify(contentApi, never()).contentEntryTagCreate(any()); - verify(contentApi, never()).contentEntryTagPartialUpdate(any(), any()); } @ParameterizedTest(name = "tag skipped when code=''{0}'' value=''{1}''") - @DisplayName("tag with blank/null code or blank/null value is skipped") @MethodSource("com.backbase.stream.investment.service.resttemplate.InvestmentRestNewsContentServiceTest#invalidTags") void tagWithInvalidCodeOrValueIsSkipped(String code, String value) { ContentTag tag = new ContentTag(code, value); @@ -96,7 +162,6 @@ void tagWithInvalidCodeOrValueIsSkipped(String code, String value) { .verifyComplete(); verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); - verify(contentApi, never()).contentEntryTagCreate(any()); } @Test @@ -113,65 +178,6 @@ void newTagTriggersCreate() { .verifyComplete(); verify(contentApi, times(1)).contentEntryTagCreate(any()); - verify(contentApi, never()).contentEntryTagPartialUpdate(any(), any()); - } - - @Test - @DisplayName("existing tag triggers patch") - void existingTagTriggersPatch() { - ContentTag tag = new ContentTag("code1", "value1"); - EntryTag existingTag = new EntryTag().code("code1").value("oldValue"); - PaginatedEntryTagList page = new PaginatedEntryTagList().results(List.of(existingTag)); - EntryTag patchedTag = new EntryTag().code("code1").value("value1"); - - when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(page); - when(contentApi.contentEntryTagPartialUpdate(anyString(), any())).thenReturn(patchedTag); - - StepVerifier.create(service.upsertTags(List.of(tag))) - .verifyComplete(); - - verify(contentApi, times(1)).contentEntryTagPartialUpdate(anyString(), any()); - verify(contentApi, never()).contentEntryTagCreate(any()); - } - - @Test - @DisplayName("API failure on tag create is swallowed and processing continues") - void apiFailureOnTagCreateIsSwallowed() { - ContentTag tag1 = new ContentTag("code1", "value1"); - ContentTag tag2 = new ContentTag("code2", "value2"); - PaginatedEntryTagList emptyPage = new PaginatedEntryTagList().results(List.of()); - EntryTag createdTag2 = new EntryTag().code("code2").value("value2"); - - when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(emptyPage); - when(contentApi.contentEntryTagCreate(any())) - .thenThrow(new RuntimeException("API error")) - .thenReturn(createdTag2); - - StepVerifier.create(service.upsertTags(List.of(tag1, tag2))) - .verifyComplete(); - - verify(contentApi, times(2)).contentEntryTagCreate(any()); - } - - @Test - @DisplayName("multiple tags: existing gets patched, new gets created") - void multipleTagsAllProcessed() { - ContentTag tag1 = new ContentTag("code1", "value1"); - ContentTag tag2 = new ContentTag("code2", "value2"); - EntryTag existingTag1 = new EntryTag().code("code1").value("old1"); - PaginatedEntryTagList page = new PaginatedEntryTagList().results(List.of(existingTag1)); - EntryTag patchedTag = new EntryTag().code("code1").value("value1"); - EntryTag createdTag = new EntryTag().code("code2").value("value2"); - - when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(page); - when(contentApi.contentEntryTagPartialUpdate(anyString(), any())).thenReturn(patchedTag); - when(contentApi.contentEntryTagCreate(any())).thenReturn(createdTag); - - StepVerifier.create(service.upsertTags(List.of(tag1, tag2))) - .verifyComplete(); - - verify(contentApi, times(1)).contentEntryTagPartialUpdate(anyString(), any()); - verify(contentApi, times(1)).contentEntryTagCreate(any()); } } @@ -184,46 +190,69 @@ void multipleTagsAllProcessed() { class UpsertContent { @Test - @DisplayName("empty list completes without calling API") - void emptyListCompletesWithoutApiCall() { + @DisplayName("no thumbnail uses JSON POST without thumbnail field") + void noThumbnailUsesJsonPost() throws Exception { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("News Title"); + entry.setTags(List.of("INVESTOR_AREA")); + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); + created.setTitle("News Title"); + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + stubJsonContentEntryCreate(created); - StepVerifier.create(service.upsertContent(List.of())) + StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); - verify(contentApi, never()).createContentEntry(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(); + assertThat(body.get(JSON_PROPERTY_ASSETS)).isEmpty(); } @Test - @DisplayName("no existing entries - all entries are created") - void noExistingEntriesAllCreated() { + @DisplayName("thumbnail resource uses JSON create then multipart PATCH for thumbnail") + void thumbnailUsesJsonCreateThenMultipartPatch() { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("News Title"); + entry.setTags(List.of("INVESTOR_AREA")); + entry.setThumbnailResource(new ByteArrayResource("image".getBytes(), "example.png")); PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); - // EntryCreateUpdate: uuid/assets/createdBy are constructor-only; title has setTitle() - EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); + UUID createdUuid = UUID.randomUUID(); + EntryCreateUpdate created = new EntryCreateUpdate(createdUuid, null, null); created.setTitle("News Title"); + EntryCreateUpdate patched = new EntryCreateUpdate(createdUuid, null, null); + patched.setTitle("News Title"); when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - when(contentApi.createContentEntry(any())).thenReturn(created); + stubJsonContentEntryCreate(created); + stubMultipartContentEntryPatch(createdUuid, patched); StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); - verify(contentApi, times(1)).createContentEntry(any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), eq(Map.of("uuid", createdUuid.toString())), + any(), isNull(), any(), any(), any(), any(), eq(MediaType.MULTIPART_FORM_DATA), any(), any()); + assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_THUMBNAIL)).hasSize(1); + assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_ASSETS)).isNull(); } @Test - @DisplayName("existing entries with matching title are skipped (not duplicated)") + @DisplayName("existing entries with matching title are skipped") void existingEntriesWithMatchingTitleAreSkipped() { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("Existing News"); - // Entry: uuid and title are constructor-only (both in @JsonCreator) Entry existingEntry = new Entry(UUID.randomUUID(), "Existing News", null, null, null, null, null, null, null, null); PaginatedEntryList page = new PaginatedEntryList().results(List.of(existingEntry)); @@ -234,31 +263,9 @@ void existingEntriesWithMatchingTitleAreSkipped() { StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); - verify(contentApi, never()).createContentEntry(any()); - } - - @Test - @DisplayName("only new entries (not matching existing) are created") - void onlyNewEntriesAreCreated() { - MarketNewsEntry existingEntry = new MarketNewsEntry(); - existingEntry.setTitle("Existing News"); - MarketNewsEntry newEntry = new MarketNewsEntry(); - newEntry.setTitle("Brand New News"); - - Entry serverEntry = new Entry(UUID.randomUUID(), "Existing News", - null, null, null, null, null, null, null, null); - PaginatedEntryList page = new PaginatedEntryList().results(List.of(serverEntry)); - EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); - created.setTitle("Brand New News"); - - when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), - isNull(), isNull(), isNull(), isNull())).thenReturn(page); - when(contentApi.createContentEntry(any())).thenReturn(created); - - StepVerifier.create(service.upsertContent(List.of(existingEntry, newEntry))) - .verifyComplete(); - - verify(contentApi, times(1)).createContentEntry(any()); + verify(apiClient, never()).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any()); } @Test @@ -266,8 +273,10 @@ void onlyNewEntriesAreCreated() { void apiFailureOnCreateIsSwallowed() { MarketNewsEntry entry1 = new MarketNewsEntry(); entry1.setTitle("News 1"); + entry1.setTags(List.of("INVESTOR_AREA")); MarketNewsEntry entry2 = new MarketNewsEntry(); entry2.setTitle("News 2"); + entry2.setTags(List.of("INVESTOR_AREA")); PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); @@ -275,38 +284,30 @@ void apiFailureOnCreateIsSwallowed() { when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - when(contentApi.createContentEntry(any())) + 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/"), + eq(HttpMethod.POST), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + eq(MediaType.APPLICATION_JSON), + any(), + any())) .thenThrow(new RuntimeException("API failure")) - .thenReturn(created); + .thenReturn(ResponseEntity.ok(created)); StepVerifier.create(service.upsertContent(List.of(entry1, entry2))) .verifyComplete(); - verify(contentApi, times(2)).createContentEntry(any()); - } - - @Test - @DisplayName("entry without thumbnail skips thumbnail PATCH call via apiClient") - void entryWithoutThumbnailSkipsThumbnailAttachment() { - MarketNewsEntry entry = new MarketNewsEntry(); - entry.setTitle("No Thumbnail News"); - // thumbnailResource is null by default - - PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); - EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); - created.setTitle("No Thumbnail News"); - - when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), - isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - when(contentApi.createContentEntry(any())).thenReturn(created); - - StepVerifier.create(service.upsertContent(List.of(entry))) - .verifyComplete(); - - // No thumbnail PATCH via apiClient - verify(apiClient, never()).invokeAPI(anyString(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), any()); + verify(apiClient, times(2)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); } } } - From e3dd95283ea1df6e5373fd7825babaef0aa14b11 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Mon, 27 Jul 2026 16:43:18 +0530 Subject: [PATCH 2/3] add extra_data to portfolio --- ...InvestmentRestServiceApiConfiguration.java | 12 -- .../investment/InvestmentArrangement.java | 2 + .../service/InvestmentPortfolioService.java | 13 +- .../InvestmentPortfolioServiceTest.java | 113 ++++++++++++++++++ 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java index 9e724a636..b218b5027 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java @@ -57,14 +57,6 @@ public com.backbase.investment.api.service.sync.ApiClient restInvestmentApiClien return apiClient; } - /** - * Dedicated {@link ObjectMapper} for investment RestTemplate calls. - * - *

Uses {@link Include#NON_EMPTY} to omit blank optional fields in multipart requests. - * JSON content entry create serialises payloads to {@code byte[]} explicitly in - * {@link InvestmentRestNewsContentService} to avoid {@code RestTemplate} {@code ObjectNode} - * serialisation issues. - */ @Bean @Qualifier("restInvestmentObjectMapper") public ObjectMapper restInvestmentObjectMapper(ObjectMapper legacyObjectMapper) { @@ -88,10 +80,6 @@ public com.backbase.investment.api.service.sync.v1.AssetUniverseApi restAssetUni return new com.backbase.investment.api.service.sync.v1.AssetUniverseApi(restInvestmentApiClient); } - /** - * RestTemplate-based news content service. Requires {@code restInvestmentObjectMapper} for - * manual JSON serialisation in {@link InvestmentRestNewsContentService}. - */ @Bean @Primary public InvestmentRestNewsContentService investmentNewsContentService( diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java index 16f542d49..9253d3d52 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/InvestmentArrangement.java @@ -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; @@ -20,6 +21,7 @@ public class InvestmentArrangement { private String productPortfolioName; private BigDecimal initialCash; private BigDecimal withdrawalAmount; + private Map extraData; private UUID investmentProductId; private List legalEntityIds; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java index 358bf7ad6..aabca5fc5 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioService.java @@ -187,10 +187,11 @@ private Mono patchPortfolio( .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 -> { @@ -237,7 +238,11 @@ private Mono createNewPortfolio(InvestmentArrangement investmentA .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())) + .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( diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java index 06a925af4..412518b35 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioServiceTest.java @@ -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; @@ -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; @@ -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; @@ -599,6 +603,115 @@ void upsertInvestmentPortfolios_noExistingPortfolio_createsNew() { 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 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); + 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 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 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); + 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 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); + 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 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() { From 613593aa307505475fb6fae3eba93b20e2725754 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Mon, 27 Jul 2026 19:46:57 +0530 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 7 +- .../InvestmentRestNewsContentService.java | 262 +++++++++++------- .../InvestmentRestNewsContentServiceTest.java | 254 ++++++++++++++--- 3 files changed, 376 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d27c3d0e6..c11125b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 2a7d1478d..4703ec0f2 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 @@ -11,7 +11,6 @@ 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.PatchedEntryTagRequest; -import com.backbase.investment.api.service.sync.v1.model.RelatedAssetSerializerWithAssetCategoriesRequest; import com.backbase.stream.investment.model.MarketNewsEntry; import com.backbase.stream.investment.model.ContentTag; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,9 +20,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; 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; @@ -36,29 +35,32 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClientException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** - * RestTemplate-based service for market news content and tags against the Investment service - * {@code /service-api/v2/content/} endpoints. + * REST client service for upserting market news content and tags via the Investment Content API. * - *

This service avoids serialisation issues present in the auto-generated - * {@code ContentApi#createContentEntry} client introduced with investment-service-api 1.6.x: + *

This service manages: *

    - *
  • explicit {@code thumbnail: null} is rejected by the investment API
  • - *
  • empty {@code assets} arrays must be present on create
  • - *
  • multipart create does not reliably transmit JSON array fields
  • - *
  • {@code ObjectNode} request bodies are not always serialised by {@code RestTemplate}
  • + *
  • Market news tag creation and updates
  • + *
  • Market news content entry creation (skips duplicates by title)
  • + *
  • Thumbnail attachment for newly created content entries
  • *
* *

Content entry create uses JSON {@code POST /service-api/v2/content/entries/} via - * {@link ApiClient#invokeAPI}; optional thumbnail upload uses multipart - * {@code PATCH /service-api/v2/content/entries/{uuid}/}. Mapping from the stream - * {@link MarketNewsEntry} model is handled by {@link ContentMapper}. + * {@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. * - * @see InvestmentRestDocumentContentService + *

Design notes (see CODING_RULES_COPILOT.md): + *

    + *
  • No direct manipulation of generated API classes beyond construction and mapping
  • + *
  • Side-effecting operations are logged at info (create) or debug (patch) levels
  • + *
  • Individual entry failures are logged and swallowed so batch processing continues
  • + *
  • All reactive operations include proper success and error handlers for observability
  • + *
*/ @Slf4j @RequiredArgsConstructor @@ -68,10 +70,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 static final String[] MULTIPART_CONTENT_TYPES = {"multipart/form-data"}; private final ContentApi contentApi; private final ApiClient apiClient; @@ -79,10 +78,12 @@ public class InvestmentRestNewsContentService { private final ContentMapper contentMapper = Mappers.getMapper(ContentMapper.class); /** - * Creates or updates market news tags via {@code ContentApi} entry-tag endpoints. + * Upserts a batch of content tags. For each tag, checks whether a tag with the same code already exists. + * If found, patches it; otherwise creates a new tag. Tags with blank code or value are skipped. + * Individual failures are logged and swallowed so remaining tags continue processing. * - * @param tagEntries tags to upsert - * @return {@link Mono} that completes when all tags have been processed + * @param tagEntries list of tags to upsert + * @return Mono that completes when all tags have been processed */ public Mono upsertTags(List tagEntries) { log.info("Starting tag upsert batch operation: totalEntriesSubmitted={}", tagEntries.size()); @@ -101,31 +102,19 @@ public Mono upsertTags(List tagEntries) { } /** - * Creates new market news content entries that are not already present in the investment service. + * Upserts a single tag entry using the ContentApi tag endpoints. Implementation follows the upsert pattern: + *
    + *
  1. List existing tag entries to check if the tag code already exists
  2. + *
  3. If tag exists, patch it with the new value
  4. + *
  5. If not found, create a new tag entry
  6. + *
* - * @param contentEntries news entries to upsert - * @return {@link Mono} that completes when all new entries have been processed + * @param marketNewsTag the tag to upsert + * @return Mono that completes with the tag when processed, or empty if validation fails or an error occurs */ - public Mono upsertContent(List contentEntries) { - log.info("Starting content upsert batch operation: totalEntriesSubmitted={}", contentEntries.size()); - log.debug("Content upsert batch details: entries={}", contentEntries); - - return findEntriesNewContent(contentEntries) - .flatMap(this::upsertSingleEntry) - .count() - .doOnNext(entriesCreated -> log.info( - "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesCreated={}", - contentEntries.size(), entriesCreated)) - .doOnError(error -> log.error( - "Content upsert batch failed: totalEntriesSubmitted={}, errorType={}, errorMessage={}", - contentEntries.size(), error.getClass().getSimpleName(), error.getMessage(), error)) - .then(); - } - private Mono upsertSingleTag(ContentTag marketNewsTag) { log.debug("Processing tag: code='{}', value='{}'", marketNewsTag.getCode(), marketNewsTag.getValue()); - // Validation if (marketNewsTag.getCode() == null || marketNewsTag.getCode().isBlank()) { log.warn("Skipping tag with empty code: value='{}'", marketNewsTag.getValue()); return Mono.empty(); @@ -139,8 +128,8 @@ private Mono upsertSingleTag(ContentTag marketNewsTag) { log.debug("Checking if tag entry exists: code='{}', value='{}'", marketNewsTag.getCode(), marketNewsTag.getValue()); - // Check if tag entry already exists - return Mono.fromCallable(() -> contentApi.contentEntryTagList(CONTENT_RETRIEVE_LIMIT, 0)) + return Mono.fromCallable(() -> + contentApi.contentEntryTagList(CONTENT_RETRIEVE_LIMIT, 0)) .map(paginatedList -> paginatedList.getResults().stream() .filter(Objects::nonNull) .filter(entry -> marketNewsTag.getCode().equals(entry.getCode())) @@ -166,6 +155,12 @@ private Mono upsertSingleTag(ContentTag marketNewsTag) { }); } + /** + * Creates a new tag entry using the ContentApi. + * + * @param contentTag the tag to create an entry for + * @return Mono of the created tag + */ private Mono createTagEntry(ContentTag contentTag) { EntryTagRequest request = new EntryTagRequest() .code(contentTag.getCode()) @@ -182,6 +177,12 @@ private Mono createTagEntry(ContentTag contentTag) { .thenReturn(contentTag); } + /** + * Patches an existing tag entry with updated values. + * + * @param contentTag the tag with updated values to patch + * @return Mono of the patched tag + */ private Mono patchTagEntry(ContentTag contentTag) { PatchedEntryTagRequest request = new PatchedEntryTagRequest() .code(contentTag.getCode()) @@ -198,11 +199,46 @@ private Mono patchTagEntry(ContentTag contentTag) { .thenReturn(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 + */ + 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) + .flatMap(this::upsertSingleEntry) + .count() + .doOnNext(entriesCreated -> log.info( + "Content upsert batch completed successfully: totalEntriesSubmitted={}, entriesCreated={}", + contentEntries.size(), entriesCreated)) + .doOnError(error -> log.error( + "Content upsert batch failed: totalEntriesSubmitted={}, errorType={}, errorMessage={}", + contentEntries.size(), error.getClass().getSimpleName(), error.getMessage(), error)) + .then(); + } + + /** + * 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. + * + * @param request the content entry to create + * @return Mono that completes with the created entry, or empty if creation fails + */ private Mono upsertSingleEntry(MarketNewsEntry request) { log.debug("Creating content entry: title='{}', hasThumbnail={}", request.getTitle(), request.getThumbnailResource() != null); - return Mono.defer(() -> Mono.fromCallable(() -> invokeCreateContentEntry(request))) + 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)) @@ -213,26 +249,26 @@ private Mono upsertSingleEntry(MarketNewsEntry request) { } /** - * Creates a content entry and optionally attaches a thumbnail file. + * Creates a content entry via JSON POST. * - * @throws RestClientException if the investment service returns an error + *

{@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 invokeCreateContentEntry(MarketNewsEntry entry) throws RestClientException { - EntryCreateUpdateRequest request = contentMapper.map(entry); - EntryCreateUpdate created = invokeCreate(request); + 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()))); - Resource thumbnail = entry.getThumbnailResource(); - if (thumbnail != null) { - return invokePatchThumbnail(created.getUuid(), thumbnail); + final byte[] bodyBytes; + try { + bodyBytes = objectMapper.writeValueAsBytes(requestBody); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Failed to serialize content entry create request", exception); } - return created; - } - - private EntryCreateUpdate invokeCreate(EntryCreateUpdateRequest request) throws RestClientException { - byte[] bodyBytes = buildCreateRequestBodyBytes(request); - log.debug("Creating content entry JSON payload: title='{}'", request.getTitle()); - final List accept = apiClient.selectHeaderAccept(new String[]{"application/json"}); + final List accept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES); final MediaType contentType = apiClient.selectHeaderContentType(JSON_CONTENT_TYPES); ParameterizedTypeReference returnType = new ParameterizedTypeReference<>() { }; @@ -253,56 +289,13 @@ private EntryCreateUpdate invokeCreate(EntryCreateUpdateRequest request) throws .getBody(); } - private EntryCreateUpdate invokePatchThumbnail(UUID uuid, Resource thumbnail) throws RestClientException { - final Map uriVariables = new HashMap<>(); - uriVariables.put("uuid", uuid.toString()); - - final MultiValueMap formParams = new LinkedMultiValueMap<>(); - log.debug("Patching content entry thumbnail: uuid={}, thumbnailFile='{}'", - uuid, getFileNameForLog(thumbnail)); - formParams.add(JSON_PROPERTY_THUMBNAIL, thumbnail); - - final List accept = apiClient.selectHeaderAccept(new String[]{"application/json"}); - final MediaType contentType = apiClient.selectHeaderContentType(MULTIPART_CONTENT_TYPES); - ParameterizedTypeReference returnType = new ParameterizedTypeReference<>() { - }; - - return apiClient.invokeAPI( - PATCH_CONTENT_ENTRY_PATH, - HttpMethod.PATCH, - uriVariables, - new LinkedMultiValueMap<>(), - null, - new HttpHeaders(), - new LinkedMultiValueMap<>(), - formParams, - accept, - contentType, - new String[]{}, - returnType) - .getBody(); - } - /** - * Builds JSON request bytes for content entry create. + * 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. * - *

{@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. + * @param contentEntries entries to filter + * @return Flux of entries that should be created */ - private byte[] buildCreateRequestBodyBytes(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()))); - - try { - return objectMapper.writeValueAsBytes(requestBody); - } catch (JsonProcessingException exception) { - throw new IllegalStateException("Failed to serialize content entry create request", exception); - } - } - private Flux findEntriesNewContent(List contentEntries) { Map entryByTitle = contentEntries.stream() .collect(Collectors.toMap(MarketNewsEntry::getTitle, Function.identity())); @@ -332,4 +325,61 @@ private Flux findEntriesNewContent(List conten return Flux.fromIterable(newEntries); } + + /** + * Attaches a thumbnail to a content entry via multipart PATCH when a thumbnail resource is provided. + * Failures are logged and swallowed so the created entry is retained without a thumbnail. + * + * @param entry the created content entry + * @param thumbnail optional thumbnail resource + * @return Mono emitting the entry (unchanged on success or when attachment is skipped or fails) + */ + private Mono addThumbnail(EntryCreateUpdate entry, Resource thumbnail) { + UUID uuid = entry.getUuid(); + + if (thumbnail == null) { + log.debug("Skipping thumbnail attachment: uuid={}, title='{}'", uuid, entry.getTitle()); + return Mono.just(entry); + } + + log.debug("Attaching thumbnail to content entry: uuid={}, title='{}', thumbnailFile='{}'", uuid, + entry.getTitle(), getFileNameForLog(thumbnail)); + + return Mono.defer(() -> { + Map uriVariables = new HashMap<>(); + uriVariables.put("uuid", uuid); + + MultiValueMap localVarQueryParams = new LinkedMultiValueMap<>(); + HttpHeaders localVarHeaderParams = new HttpHeaders(); + MultiValueMap localVarCookieParams = new LinkedMultiValueMap<>(); + MultiValueMap localVarFormParams = new LinkedMultiValueMap<>(); + + localVarFormParams.add(JSON_PROPERTY_THUMBNAIL, thumbnail); + + final List localVarAccept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES); + final String[] localVarContentTypes = {"multipart/form-data"}; + final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[]{}; + + ParameterizedTypeReference localReturnType = new ParameterizedTypeReference<>() { + }; + apiClient.invokeAPI("/service-api/v2/content/entries/{uuid}/", HttpMethod.PATCH, uriVariables, + localVarQueryParams, null, localVarHeaderParams, localVarCookieParams, localVarFormParams, + localVarAccept, localVarContentType, localVarAuthNames, localReturnType); + + log.debug("Thumbnail attached successfully: uuid={}, title='{}', thumbnailFile='{}'", uuid, + entry.getTitle(), getFileNameForLog(thumbnail)); + return Mono.just(entry); + }).doOnError(error -> log.error( + "Thumbnail attachment failed: uuid={}, title='{}', thumbnailFile='{}', errorType={}, errorMessage={}", + uuid, entry.getTitle(), getFileNameForLog(thumbnail), error.getClass().getSimpleName(), + error.getMessage(), error)) + .onErrorResume(error -> { + log.warn("Content entry created without thumbnail: uuid={}, title='{}', reason={}", uuid, + entry.getTitle(), error.getMessage()); + return Mono.just(entry); + }); + } + } 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 de0d7deb0..41aff85ae 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 @@ -48,12 +48,6 @@ import org.springframework.util.MultiValueMap; import reactor.test.StepVerifier; -/** - * Unit tests for {@link InvestmentRestNewsContentService}. - * - *

Verifies tag upsert via generated {@code ContentApi} clients and content entry create via - * manual {@code ApiClient} calls (JSON POST with {@code byte[]} body, optional multipart thumbnail PATCH). - */ @ExtendWith(MockitoExtension.class) class InvestmentRestNewsContentServiceTest { @@ -105,7 +99,7 @@ private void stubMultipartContentEntryPatch(UUID uuid, EntryCreateUpdate patched when(apiClient.invokeAPI( eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), - eq(Map.of("uuid", uuid.toString())), + eq(Map.of("uuid", uuid)), any(), isNull(), any(), @@ -119,9 +113,9 @@ private void stubMultipartContentEntryPatch(UUID uuid, EntryCreateUpdate patched static Stream invalidTags() { return Stream.of( - org.junit.jupiter.params.provider.Arguments.of(" ", "someValue"), - org.junit.jupiter.params.provider.Arguments.of("code1", null), - org.junit.jupiter.params.provider.Arguments.of("code1", " ") + org.junit.jupiter.params.provider.Arguments.of(" ", "someValue"), // blank code + org.junit.jupiter.params.provider.Arguments.of("code1", null), // null value + org.junit.jupiter.params.provider.Arguments.of("code1", " ") // blank value ); } @@ -151,9 +145,12 @@ void tagWithNullCodeIsSkipped() { .verifyComplete(); verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); + verify(contentApi, never()).contentEntryTagCreate(any()); + verify(contentApi, never()).contentEntryTagPartialUpdate(any(), any()); } @ParameterizedTest(name = "tag skipped when code=''{0}'' value=''{1}''") + @DisplayName("tag with blank/null code or blank/null value is skipped") @MethodSource("com.backbase.stream.investment.service.resttemplate.InvestmentRestNewsContentServiceTest#invalidTags") void tagWithInvalidCodeOrValueIsSkipped(String code, String value) { ContentTag tag = new ContentTag(code, value); @@ -162,6 +159,7 @@ void tagWithInvalidCodeOrValueIsSkipped(String code, String value) { .verifyComplete(); verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); + verify(contentApi, never()).contentEntryTagCreate(any()); } @Test @@ -178,6 +176,65 @@ void newTagTriggersCreate() { .verifyComplete(); verify(contentApi, times(1)).contentEntryTagCreate(any()); + verify(contentApi, never()).contentEntryTagPartialUpdate(any(), any()); + } + + @Test + @DisplayName("existing tag triggers patch") + void existingTagTriggersPatch() { + ContentTag tag = new ContentTag("code1", "value1"); + EntryTag existingTag = new EntryTag().code("code1").value("oldValue"); + PaginatedEntryTagList page = new PaginatedEntryTagList().results(List.of(existingTag)); + EntryTag patchedTag = new EntryTag().code("code1").value("value1"); + + when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(page); + when(contentApi.contentEntryTagPartialUpdate(anyString(), any())).thenReturn(patchedTag); + + StepVerifier.create(service.upsertTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, times(1)).contentEntryTagPartialUpdate(anyString(), any()); + verify(contentApi, never()).contentEntryTagCreate(any()); + } + + @Test + @DisplayName("API failure on tag create is swallowed and processing continues") + void apiFailureOnTagCreateIsSwallowed() { + ContentTag tag1 = new ContentTag("code1", "value1"); + ContentTag tag2 = new ContentTag("code2", "value2"); + PaginatedEntryTagList emptyPage = new PaginatedEntryTagList().results(List.of()); + EntryTag createdTag2 = new EntryTag().code("code2").value("value2"); + + when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(emptyPage); + when(contentApi.contentEntryTagCreate(any())) + .thenThrow(new RuntimeException("API error")) + .thenReturn(createdTag2); + + StepVerifier.create(service.upsertTags(List.of(tag1, tag2))) + .verifyComplete(); + + verify(contentApi, times(2)).contentEntryTagCreate(any()); + } + + @Test + @DisplayName("multiple tags: existing gets patched, new gets created") + void multipleTagsAllProcessed() { + ContentTag tag1 = new ContentTag("code1", "value1"); + ContentTag tag2 = new ContentTag("code2", "value2"); + EntryTag existingTag1 = new EntryTag().code("code1").value("old1"); + PaginatedEntryTagList page = new PaginatedEntryTagList().results(List.of(existingTag1)); + EntryTag patchedTag = new EntryTag().code("code1").value("value1"); + EntryTag createdTag = new EntryTag().code("code2").value("value2"); + + when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(page); + when(contentApi.contentEntryTagPartialUpdate(anyString(), any())).thenReturn(patchedTag); + when(contentApi.contentEntryTagCreate(any())).thenReturn(createdTag); + + StepVerifier.create(service.upsertTags(List.of(tag1, tag2))) + .verifyComplete(); + + verify(contentApi, times(1)).contentEntryTagPartialUpdate(anyString(), any()); + verify(contentApi, times(1)).contentEntryTagCreate(any()); } } @@ -190,49 +247,34 @@ void newTagTriggersCreate() { class UpsertContent { @Test - @DisplayName("no thumbnail uses JSON POST without thumbnail field") - void noThumbnailUsesJsonPost() throws Exception { - MarketNewsEntry entry = new MarketNewsEntry(); - entry.setTitle("News Title"); - entry.setTags(List.of("INVESTOR_AREA")); - + @DisplayName("empty list completes without calling API") + void emptyListCompletesWithoutApiCall() { PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); - EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); - created.setTitle("News Title"); - when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - stubJsonContentEntryCreate(created); - StepVerifier.create(service.upsertContent(List.of(entry))) + StepVerifier.create(service.upsertContent(List.of())) .verifyComplete(); - 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(); - assertThat(body.get(JSON_PROPERTY_ASSETS)).isEmpty(); + verify(apiClient, never()).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any()); } @Test - @DisplayName("thumbnail resource uses JSON create then multipart PATCH for thumbnail") - void thumbnailUsesJsonCreateThenMultipartPatch() { + @DisplayName("no existing entries - all entries are created via JSON POST") + void noExistingEntriesAllCreated() throws Exception { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("News Title"); entry.setTags(List.of("INVESTOR_AREA")); - entry.setThumbnailResource(new ByteArrayResource("image".getBytes(), "example.png")); PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); - UUID createdUuid = UUID.randomUUID(); - EntryCreateUpdate created = new EntryCreateUpdate(createdUuid, null, null); + EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); created.setTitle("News Title"); - EntryCreateUpdate patched = new EntryCreateUpdate(createdUuid, null, null); - patched.setTitle("News Title"); when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); stubJsonContentEntryCreate(created); - stubMultipartContentEntryPatch(createdUuid, patched); StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); @@ -240,15 +282,16 @@ void thumbnailUsesJsonCreateThenMultipartPatch() { verify(apiClient, times(1)).invokeAPI( eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), any(), eq(MediaType.APPLICATION_JSON), any(), any()); - verify(apiClient, times(1)).invokeAPI( - eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), eq(Map.of("uuid", createdUuid.toString())), - any(), isNull(), any(), any(), any(), any(), eq(MediaType.MULTIPART_FORM_DATA), any(), any()); - assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_THUMBNAIL)).hasSize(1); - assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_ASSETS)).isNull(); + + 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(); + assertThat(body.get(JSON_PROPERTY_ASSETS)).isEmpty(); } @Test - @DisplayName("existing entries with matching title are skipped") + @DisplayName("existing entries with matching title are skipped (not duplicated)") void existingEntriesWithMatchingTitleAreSkipped() { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("Existing News"); @@ -268,6 +311,33 @@ void existingEntriesWithMatchingTitleAreSkipped() { any(), any(), any(), any()); } + @Test + @DisplayName("only new entries (not matching existing) are created") + void onlyNewEntriesAreCreated() { + MarketNewsEntry existingEntry = new MarketNewsEntry(); + existingEntry.setTitle("Existing News"); + MarketNewsEntry newEntry = new MarketNewsEntry(); + newEntry.setTitle("Brand New News"); + newEntry.setTags(List.of("INVESTOR_AREA")); + + Entry serverEntry = new Entry(UUID.randomUUID(), "Existing News", + null, null, null, null, null, null, null, null); + PaginatedEntryList page = new PaginatedEntryList().results(List.of(serverEntry)); + EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); + created.setTitle("Brand New News"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(page); + stubJsonContentEntryCreate(created); + + StepVerifier.create(service.upsertContent(List.of(existingEntry, newEntry))) + .verifyComplete(); + + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); + } + @Test @DisplayName("API failure on entry create is swallowed and processing continues") void apiFailureOnCreateIsSwallowed() { @@ -309,5 +379,109 @@ void apiFailureOnCreateIsSwallowed() { eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), any(), eq(MediaType.APPLICATION_JSON), any(), any()); } + + @Test + @DisplayName("entry without thumbnail skips thumbnail PATCH call via apiClient") + void entryWithoutThumbnailSkipsThumbnailAttachment() { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("No Thumbnail News"); + entry.setTags(List.of("INVESTOR_AREA")); + + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); + created.setTitle("No Thumbnail News"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + stubJsonContentEntryCreate(created); + + StepVerifier.create(service.upsertContent(List.of(entry))) + .verifyComplete(); + + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); + verify(apiClient, never()).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("thumbnail resource uses JSON create then multipart PATCH for thumbnail") + void thumbnailUsesJsonCreateThenMultipartPatch() { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("News Title"); + entry.setTags(List.of("INVESTOR_AREA")); + entry.setThumbnailResource(new ByteArrayResource("image".getBytes(), "example.png")); + + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + UUID createdUuid = UUID.randomUUID(); + EntryCreateUpdate created = new EntryCreateUpdate(createdUuid, null, null); + created.setTitle("News Title"); + EntryCreateUpdate patched = new EntryCreateUpdate(createdUuid, null, null); + patched.setTitle("News Title"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + stubJsonContentEntryCreate(created); + stubMultipartContentEntryPatch(createdUuid, patched); + + StepVerifier.create(service.upsertContent(List.of(entry))) + .verifyComplete(); + + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), + eq(Map.of("uuid", createdUuid)), any(), isNull(), any(), any(), any(), any(), + eq(MediaType.MULTIPART_FORM_DATA), any(), any()); + assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_THUMBNAIL)).hasSize(1); + assertThat(formParamsCaptor.getValue().get(JSON_PROPERTY_ASSETS)).isNull(); + } + + @Test + @DisplayName("thumbnail PATCH failure retains created entry") + void thumbnailPatchFailureRetainsCreatedEntry() { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("News Title"); + entry.setTags(List.of("INVESTOR_AREA")); + entry.setThumbnailResource(new ByteArrayResource("image".getBytes(), "example.png")); + + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + UUID createdUuid = UUID.randomUUID(); + EntryCreateUpdate created = new EntryCreateUpdate(createdUuid, null, null); + created.setTitle("News Title"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + stubJsonContentEntryCreate(created); + when(apiClient.selectHeaderContentType(new String[]{"multipart/form-data"})) + .thenReturn(MediaType.MULTIPART_FORM_DATA); + when(apiClient.invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), + eq(HttpMethod.PATCH), + eq(Map.of("uuid", createdUuid)), + any(), + isNull(), + any(), + any(), + any(), + any(), + eq(MediaType.MULTIPART_FORM_DATA), + any(), + any())).thenThrow(new RuntimeException("thumbnail upload failed")); + + StepVerifier.create(service.upsertContent(List.of(entry))) + .verifyComplete(); + + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/"), eq(HttpMethod.POST), any(), any(), any(), any(), any(), any(), + any(), eq(MediaType.APPLICATION_JSON), any(), any()); + verify(apiClient, times(1)).invokeAPI( + eq("/service-api/v2/content/entries/{uuid}/"), eq(HttpMethod.PATCH), + eq(Map.of("uuid", createdUuid)), any(), isNull(), any(), any(), any(), any(), + eq(MediaType.MULTIPART_FORM_DATA), any(), any()); + } } }