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/configuration/InvestmentRestServiceApiConfiguration.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/configuration/InvestmentRestServiceApiConfiguration.java index 65d85466b..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 @@ -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 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/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..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 @@ -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; @@ -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; @@ -42,6 +48,12 @@ *
  • Thumbnail attachment for newly created content entries
  • * * + *

    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. + * *

    Design notes (see CODING_RULES_COPILOT.md): *

      *
    • No direct manipulation of generated API classes beyond construction and mapping
    • @@ -56,8 +68,13 @@ 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[] 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); /** @@ -190,7 +207,7 @@ private Mono patchTagEntry(ContentTag contentTag) { * @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.info("Starting content entries upsert batch operation: totalEntriesSubmitted={}", contentEntries.size()); log.debug("Content upsert batch details: entries={}", contentEntries); return findEntriesNewContent(contentEntries) @@ -220,7 +237,7 @@ private Mono upsertSingleEntry(MarketNewsEntry request) { 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={}", @@ -231,6 +248,47 @@ private Mono upsertSingleEntry(MarketNewsEntry request) { .onErrorResume(error -> Mono.empty()); } + /** + * Creates a content entry via JSON POST. + * + *

      {@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 accept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES); + 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(); + } + /** * 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. @@ -296,10 +354,9 @@ private Mono addThumbnail(EntryCreateUpdate entry, Resource t MultiValueMap localVarCookieParams = new LinkedMultiValueMap<>(); MultiValueMap localVarFormParams = new LinkedMultiValueMap<>(); - localVarFormParams.add("thumbnail", thumbnail); + localVarFormParams.add(JSON_PROPERTY_THUMBNAIL, thumbnail); - final String[] localVarAccepts = {"application/json"}; - final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); + final List localVarAccept = apiClient.selectHeaderAccept(JSON_CONTENT_TYPES); final String[] localVarContentTypes = {"multipart/form-data"}; final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); 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/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() { 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..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 @@ -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,8 +37,15 @@ 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; @ExtendWith(MockitoExtension.class) @@ -41,11 +57,58 @@ 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)), + any(), + isNull(), + any(), + any(), + formParamsCaptor.capture(), + any(), + eq(MediaType.MULTIPART_FORM_DATA), + any(), + any())).thenReturn(ResponseEntity.ok(patched)); } static Stream invalidTags() { @@ -193,28 +256,38 @@ void emptyListCompletesWithoutApiCall() { StepVerifier.create(service.upsertContent(List.of())) .verifyComplete(); - verify(contentApi, never()).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 - @DisplayName("no existing entries - all entries are created") - void noExistingEntriesAllCreated() { + @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")); 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); created.setTitle("News Title"); when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - when(contentApi.createContentEntry(any())).thenReturn(created); + stubJsonContentEntryCreate(created); 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()); + + 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 @@ -223,7 +296,6 @@ 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,7 +306,9 @@ void existingEntriesWithMatchingTitleAreSkipped() { StepVerifier.create(service.upsertContent(List.of(entry))) .verifyComplete(); - verify(contentApi, never()).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 @@ -244,6 +318,7 @@ void onlyNewEntriesAreCreated() { 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); @@ -253,12 +328,14 @@ void onlyNewEntriesAreCreated() { when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(page); - when(contentApi.createContentEntry(any())).thenReturn(created); + stubJsonContentEntryCreate(created); StepVerifier.create(service.upsertContent(List.of(existingEntry, newEntry))) .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()); } @Test @@ -266,8 +343,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,14 +354,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()); + 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()); } @Test @@ -290,7 +385,7 @@ void apiFailureOnCreateIsSwallowed() { void entryWithoutThumbnailSkipsThumbnailAttachment() { MarketNewsEntry entry = new MarketNewsEntry(); entry.setTitle("No Thumbnail News"); - // thumbnailResource is null by default + entry.setTags(List.of("INVESTOR_AREA")); PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); @@ -298,15 +393,95 @@ void entryWithoutThumbnailSkipsThumbnailAttachment() { when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); - when(contentApi.createContentEntry(any())).thenReturn(created); + 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(); - // No thumbnail PATCH via apiClient - verify(apiClient, never()).invokeAPI(anyString(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), 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)), 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()); } } } -