From 9d3220c07e3504211e9d612ec60472e6999a722e Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Thu, 5 Mar 2026 12:05:51 +0530 Subject: [PATCH 1/5] saga tests --- .../saga/InvestmentContentSaga.java | 116 +- .../investment/saga/InvestmentSaga.java | 33 +- .../saga/InvestmentAssetUniverseSagaTest.java | 1352 +++++++++++++---- .../saga/InvestmentContentSagaTest.java | 809 ++++++++++ .../investment/saga/InvestmentSagaTest.java | 526 +++++++ .../service/InvestmentSagaTest.java | 119 -- 6 files changed, 2464 insertions(+), 491 deletions(-) create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentContentSagaTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java delete mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentSagaTest.java diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java index 9120b29d8..0417d54cd 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java @@ -3,12 +3,11 @@ import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; import com.backbase.stream.investment.InvestmentContentTask; import com.backbase.stream.investment.model.ContentDocumentEntry; -import com.backbase.stream.investment.service.InvestmentClientService; -import com.backbase.stream.investment.service.InvestmentPortfolioService; +import com.backbase.stream.investment.model.ContentTag; +import com.backbase.stream.investment.model.MarketNewsEntry; import com.backbase.stream.investment.service.resttemplate.InvestmentRestDocumentContentService; import com.backbase.stream.investment.service.resttemplate.InvestmentRestNewsContentService; import com.backbase.stream.worker.StreamTaskExecutor; -import com.backbase.stream.worker.model.StreamTask; import com.backbase.stream.worker.model.StreamTask.State; import java.util.List; import java.util.Objects; @@ -16,40 +15,17 @@ import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; -/** - * Saga orchestrating the complete investment client ingestion workflow. - * - *

This saga implements a multi-step process for ingesting investment data: - *

    - *
  1. Upsert investment clients - Creates or updates client records
  2. - *
  3. Upsert investment products - Creates or updates portfolio products
  4. - *
  5. Upsert investment portfolios - Creates or updates portfolios with client associations
  6. - *
- * - *

The saga uses idempotent operations to ensure safe re-execution and writes progress - * to the {@link StreamTask} history for observability. Each step builds upon the previous - * step's results, creating a complete investment setup. - * - *

Design notes: - *

- * - * @see InvestmentClientService - * @see InvestmentPortfolioService - * @see StreamTaskExecutor - */ @Slf4j @RequiredArgsConstructor public class InvestmentContentSaga implements StreamTaskExecutor { public static final String INVESTMENT = "investment-content"; public static final String OP_UPSERT = "upsert"; + public static final String RESULT_UPSERTED = "upserted"; public static final String RESULT_FAILED = "failed"; + private static final String PROCESSING_PREFIX = "Processing "; + private final InvestmentRestNewsContentService investmentRestNewsContentService; private final InvestmentRestDocumentContentService investmentRestDocumentContentService; private final InvestmentIngestionConfigurationProperties coreConfigurationProperties; @@ -63,8 +39,6 @@ public Mono executeTask(InvestmentContentTask streamTask) } log.info("Starting investment content saga execution: taskId={}, taskName={}", streamTask.getId(), streamTask.getName()); - log.info("Starting investment saga execution: taskId={}, taskName={}", - streamTask.getId(), streamTask.getName()); return upsertNewsTags(streamTask) .flatMap(this::upsertNewsContent) .flatMap(this::upsertDocumentTags) @@ -84,39 +58,90 @@ public Mono executeTask(InvestmentContentTask streamTask) } private Mono upsertNewsContent(InvestmentContentTask investmentContentTask) { + List marketNews = Objects.requireNonNullElse(investmentContentTask.getData().getMarketNews(), List.of()); + investmentContentTask.info(INVESTMENT, OP_UPSERT, null, investmentContentTask.getName(), + investmentContentTask.getId(), + PROCESSING_PREFIX + marketNews.size() + " investment news content"); + investmentContentTask.setState(State.IN_PROGRESS); return investmentRestNewsContentService - .upsertContent(Objects.requireNonNullElse(investmentContentTask.getData().getMarketNews(), List.of())) + .upsertContent(marketNews) + .doOnSuccess(v -> { + investmentContentTask.info(INVESTMENT, OP_UPSERT, RESULT_UPSERTED, investmentContentTask.getName(), + investmentContentTask.getId(), + RESULT_UPSERTED + " " + marketNews.size() + " Investment News Content"); + investmentContentTask.setState(State.COMPLETED); + }) + .doOnError(throwable -> + investmentContentTask.error(INVESTMENT, OP_UPSERT, RESULT_FAILED, investmentContentTask.getName(), + investmentContentTask.getId(), + "Failed to upsert investment news content: " + throwable.getMessage())) .thenReturn(investmentContentTask); } private Mono upsertNewsTags(InvestmentContentTask investmentContentTask) { + List newsTags = Objects.requireNonNullElse(investmentContentTask.getData().getMarketNewsTags(), List.of()); + investmentContentTask.info(INVESTMENT, OP_UPSERT, null, investmentContentTask.getName(), + investmentContentTask.getId(), + PROCESSING_PREFIX + newsTags.size() + " investment news tags"); + investmentContentTask.setState(State.IN_PROGRESS); return investmentRestNewsContentService - .upsertTags(Objects.requireNonNullElse(investmentContentTask.getData().getMarketNewsTags(), List.of())) + .upsertTags(newsTags) + .doOnSuccess(v -> { + investmentContentTask.info(INVESTMENT, OP_UPSERT, RESULT_UPSERTED, investmentContentTask.getName(), + investmentContentTask.getId(), + RESULT_UPSERTED + " " + newsTags.size() + " Investment News Tags"); + investmentContentTask.setState(State.COMPLETED); + }) + .doOnError(throwable -> + investmentContentTask.error(INVESTMENT, OP_UPSERT, RESULT_FAILED, investmentContentTask.getName(), + investmentContentTask.getId(), + "Failed to upsert investment news tags: " + throwable.getMessage())) .thenReturn(investmentContentTask); } private Mono upsertDocumentTags(InvestmentContentTask investmentContentTask) { + List documentTags = Objects.requireNonNullElse(investmentContentTask.getData().getDocumentTags(), List.of()); + investmentContentTask.info(INVESTMENT, OP_UPSERT, null, investmentContentTask.getName(), + investmentContentTask.getId(), + PROCESSING_PREFIX + documentTags.size() + " investment document tags"); + investmentContentTask.setState(State.IN_PROGRESS); return investmentRestDocumentContentService - .upsertContentTags(Objects.requireNonNullElse(investmentContentTask.getData().getDocumentTags(), List.of())) + .upsertContentTags(documentTags) + .doOnSuccess(v -> { + investmentContentTask.info(INVESTMENT, OP_UPSERT, RESULT_UPSERTED, investmentContentTask.getName(), + investmentContentTask.getId(), + RESULT_UPSERTED + " " + documentTags.size() + " Investment Document Tags"); + investmentContentTask.setState(State.COMPLETED); + }) + .doOnError(throwable -> + investmentContentTask.error(INVESTMENT, OP_UPSERT, RESULT_FAILED, investmentContentTask.getName(), + investmentContentTask.getId(), + "Failed to upsert investment document tags: " + throwable.getMessage())) .thenReturn(investmentContentTask); } private Mono upsertContentDocuments(InvestmentContentTask investmentContentTask) { - List documents = investmentContentTask.getData().getDocuments(); + List documents = + Objects.requireNonNullElse(investmentContentTask.getData().getDocuments(), List.of()); + investmentContentTask.info(INVESTMENT, OP_UPSERT, null, investmentContentTask.getName(), + investmentContentTask.getId(), + PROCESSING_PREFIX + documents.size() + " investment content documents"); + investmentContentTask.setState(State.IN_PROGRESS); return investmentRestDocumentContentService - .upsertDocuments(Objects.requireNonNullElse(documents, List.of())) + .upsertDocuments(documents) + .doOnSuccess(v -> { + investmentContentTask.info(INVESTMENT, OP_UPSERT, RESULT_UPSERTED, investmentContentTask.getName(), + investmentContentTask.getId(), + RESULT_UPSERTED + " " + documents.size() + " Investment Content Documents"); + investmentContentTask.setState(State.COMPLETED); + }) + .doOnError(throwable -> + investmentContentTask.error(INVESTMENT, OP_UPSERT, RESULT_FAILED, investmentContentTask.getName(), + investmentContentTask.getId(), + "Failed to upsert investment content documents: " + throwable.getMessage())) .thenReturn(investmentContentTask); } - /** - * Rollback is not implemented for investment saga. - * - *

Investment operations are idempotent and designed to be retried safely. - * Manual cleanup should be performed if necessary through the Investment Service API. - * - * @param streamTask the task to rollback - * @return null - rollback not implemented - */ @Override public Mono rollBack(InvestmentContentTask streamTask) { log.warn("Rollback requested for investment saga but not implemented: taskId={}, taskName={}", @@ -125,4 +150,3 @@ public Mono rollBack(InvestmentContentTask streamTask) { } } - diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java index 11d9469c1..43b2e17bc 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java @@ -87,7 +87,7 @@ public Mono executeTask(InvestmentTask streamTask) { .flatMap(this::upsertPortfolioTradingAccounts) .flatMap(this::upsertInvestmentPortfolioDeposits) .flatMap(this::upsertPortfoliosAllocations) - .doOnSuccess(completedTask -> log.info( + .doOnNext(completedTask -> log.info( "Successfully completed investment saga: taskId={}, taskName={}, state={}", completedTask.getId(), completedTask.getName(), completedTask.getState())) .doOnError(throwable -> { @@ -132,24 +132,24 @@ public Mono rollBack(InvestmentTask streamTask) { private Mono upsertPortfoliosAllocations(InvestmentTask investmentTask) { InvestmentData data = investmentTask.getData(); return asyncTaskService.checkPriceAsyncTasksFinished(data.getPriceAsyncTasks()) - .then(Flux.fromIterable(Objects.requireNonNullElse(data.getPortfolios(), List.of())) + .thenMany(Flux.fromIterable(Objects.requireNonNullElse(data.getPortfolios(), List.of())) .flatMap( p -> investmentPortfolioAllocationService.generateAllocations(p, data.getPortfolioProducts(), - investmentTask.getData().getInvestmentAssetData())) - .collectList() - .doOnError(throwable -> { - log.error("Allocation generation failed for portfolios:{} taskId={}", - data.getPortfolios().stream().map(PortfolioList::getUuid).toList(), investmentTask.getId(), - throwable); - investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, - investmentTask.getName(), investmentTask.getId(), - "Failed to upsert investment portfolio trading accounts: " + throwable.getMessage()); - }) - .map(o -> investmentTask) - ); + investmentTask.getData().getInvestmentAssetData()))) + .collectList() + .doOnError(throwable -> { + log.error("Allocation generation failed for portfolios:{} taskId={}", + data.getPortfolios().stream().map(PortfolioList::getUuid).toList(), investmentTask.getId(), + throwable); + investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, + investmentTask.getName(), investmentTask.getId(), + "Failed to upsert investment portfolio trading accounts: " + throwable.getMessage()); + }) + .map(o -> investmentTask); } + /** * Upserts investment portfolios for all investment arrangements. * @@ -287,15 +287,14 @@ private Mono upsertPortfolioTradingAccounts(InvestmentTask inves investmentTask.getId(), PROCESSING_PREFIX + accountsCount + " investment portfolio trading accounts"); return investmentPortfolioService.upsertPortfolioTradingAccounts(investmentPortfolioTradingAccounts) - .map(products -> { + .doOnNext(products -> { investmentTask.info(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_CREATED, investmentTask.getName(), investmentTask.getId(), UPSERTED_PREFIX + products.size() + " investment portfolio trading accounts"); log.info("Successfully upserted all investment portfolio trading accounts: taskId={}, productCount={}", investmentTask.getId(), products.size()); - - return investmentTask; }) + .thenReturn(investmentTask) .doOnError(throwable -> { log.error("Failed to upsert investment portfolio trading accounts: taskId={}, arrangementCount={}", investmentTask.getId(), accountsCount, throwable); diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java index d3e13e1ec..9e79eb14d 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java @@ -4,30 +4,37 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.backbase.investment.api.service.v1.model.AssetTypeEnum; import com.backbase.investment.api.service.v1.model.GroupResult; -import com.backbase.investment.api.service.v1.model.StatusA10Enum; import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; import com.backbase.stream.investment.Asset; import com.backbase.stream.investment.AssetPrice; import com.backbase.stream.investment.InvestmentAssetData; import com.backbase.stream.investment.InvestmentAssetsTask; +import com.backbase.investment.api.service.v1.model.AssetCategoryType; +import com.backbase.investment.api.service.v1.model.Currency; +import com.backbase.investment.api.service.v1.model.Market; +import com.backbase.investment.api.service.v1.model.MarketSpecialDay; +import com.backbase.stream.investment.model.AssetCategoryEntry; import com.backbase.stream.investment.RandomParam; import com.backbase.stream.investment.service.AsyncTaskService; import com.backbase.stream.investment.service.InvestmentAssetPriceService; import com.backbase.stream.investment.service.InvestmentAssetUniverseService; import com.backbase.stream.investment.service.InvestmentCurrencyService; import com.backbase.stream.investment.service.InvestmentIntradayAssetPriceService; -import java.time.Duration; +import com.backbase.stream.worker.model.StreamTask.State; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.Collections; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -36,15 +43,43 @@ import reactor.test.StepVerifier; /** - * Test suite for {@link InvestmentAssetUniverseSaga}, focusing on the asynchronous price ingestion - * workflow with polling and timeout behavior. + * Unit test suite for {@link InvestmentAssetUniverseSaga}. * - *

These tests verify: + *

This class verifies the complete orchestration logic of the saga, which drives + * the investment asset universe ingestion pipeline through the following stages: + *

    + *
  1. Currencies
  2. + *
  3. Markets
  4. + *
  5. Market Special Days
  6. + *
  7. Asset Category Types
  8. + *
  9. Asset Categories
  10. + *
  11. Assets
  12. + *
  13. Prices (batch async)
  14. + *
  15. Intraday Prices
  16. + *
+ * + *

Test strategy: + *

    + *
  • Each pipeline stage is tested in isolation via a dedicated {@code @Nested} class.
  • + *
  • Happy-path, empty-collection, and error scenarios are covered for every stage.
  • + *
  • {@code wireTrivialPipeline*()} helpers stub downstream stages so that each nested + * class can focus solely on its own stage under test.
  • + *
  • Error recovery is verified via the saga's {@code onErrorResume} handler, which + * always emits the task with {@link State#FAILED} instead of propagating the error + * signal — therefore {@link StepVerifier#verifyComplete()} is always used, never + * {@code verifyError()}.
  • + *
  • All reactive assertions use Project Reactor's {@link StepVerifier}.
  • + *
+ * + *

Mocked dependencies: *

    - *
  • Successful price ingestion with immediate completion
  • - *
  • Polling behavior when GroupResult tasks are PENDING
  • - *
  • Timeout handling when tasks remain PENDING beyond the timeout threshold
  • - *
  • Error propagation during price ingestion
  • + *
  • {@link InvestmentAssetUniverseService} – markets, special days, category types, + * categories, assets
  • + *
  • {@link InvestmentAssetPriceService} – batch price ingestion
  • + *
  • {@link InvestmentIntradayAssetPriceService} – intraday price ingestion
  • + *
  • {@link InvestmentCurrencyService} – currency upsert
  • + *
  • {@link AsyncTaskService} – async task polling
  • + *
  • {@link InvestmentIngestionConfigurationProperties} – feature flag
  • *
*/ class InvestmentAssetUniverseSagaTest { @@ -57,7 +92,11 @@ class InvestmentAssetUniverseSagaTest { @Mock private InvestmentIntradayAssetPriceService investmentIntradayAssetPriceService; + + @Mock private InvestmentCurrencyService investmentCurrencyService; + + @Mock private AsyncTaskService asyncTaskService; @Mock @@ -65,9 +104,15 @@ class InvestmentAssetUniverseSagaTest { private InvestmentAssetUniverseSaga saga; + /** + * Initialises Mockito mocks and constructs the saga under test before each test method. + * {@link InvestmentIngestionConfigurationProperties#isAssetUniverseEnabled()} is set to + * {@code true} by default so that the feature flag does not suppress any pipeline stage. + */ @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(true); saga = new InvestmentAssetUniverseSaga( assetUniverseService, investmentAssetPriceService, @@ -76,372 +121,1061 @@ void setUp() { asyncTaskService, configurationProperties ); - // Enable asset universe by default - when(configurationProperties.isAssetUniverseEnabled()).thenReturn(true); } + // ========================================================================= + // executeTask – top-level orchestration + // ========================================================================= /** - * Test successful price upsert when all GroupResult tasks complete immediately. + * Tests for the top-level {@code executeTask} method. * - *

Verifies that: - *

    - *
  • Price ingestion is invoked with correct parameters
  • - *
  • GroupResult status is polled for each returned task
  • - *
  • The method completes successfully when all tasks are non-PENDING
  • - *
  • The task is returned unchanged after successful completion
  • - *
+ *

These tests exercise the full pipeline end-to-end with all services stubbed + * to return successful responses, and also verify that a mid-pipeline failure + * causes the task to be marked {@link State#FAILED} without propagating an error signal. */ - @Test - void upsertPrices_success_immediateCompletion() { - // Given: An investment task with assets and prices - InvestmentAssetsTask task = createTestTask(); - UUID groupResultUuid1 = UUID.randomUUID(); - UUID groupResultUuid2 = UUID.randomUUID(); - - // Mock: Asset universe service methods - when(assetUniverseService.createAssets(anyList())) - .thenReturn(Flux.fromIterable(task.getData().getAssets())); - - // Mock GroupResult objects returned from price ingestion - GroupResult groupResult1 = mock(GroupResult.class); - when(groupResult1.getUuid()).thenReturn(groupResultUuid1); - - GroupResult groupResult2 = mock(GroupResult.class); - when(groupResult2.getUuid()).thenReturn(groupResultUuid2); + @Nested + @DisplayName("executeTask") + class ExecuteTaskTests { + + /** + * Verifies that when all services succeed, {@code executeTask} completes normally + * and the returned task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete successfully when all services succeed") + void executeTask_allServicesSucceed_completesNormally() { + InvestmentAssetsTask task = createFullTask(); + stubAllServicesSuccess(task); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that when a service throws an error, the task is marked {@link State#FAILED} + * and the reactive stream completes without emitting an error signal. + */ + @Test + @DisplayName("should mark task FAILED and complete stream when a service throws an error") + void executeTask_serviceThrowsError_marksTaskFailed() { + InvestmentAssetsTask task = createFullTask(); + + when(investmentCurrencyService.upsertCurrencies(anyList())) + .thenReturn(Mono.error(new RuntimeException("Currency service down"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that a minimal task with no data collections still completes successfully + * without invoking any upsert services due to empty-list short-circuiting. + */ + @Test + @DisplayName("should complete with empty task data") + void executeTask_emptyTask_completesNormally() { + InvestmentAssetsTask task = createMinimalTask(); + + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that when the feature flag is disabled, the saga skips all processing + * and returns the task unchanged without calling any service. + */ + @Test + @DisplayName("should skip all processing when feature flag is disabled") + void executeTask_featureFlagDisabled_skipsAllProcessing() { + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false); + InvestmentAssetsTask task = createFullTask(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> { + assertThat(result.getState()).isNotEqualTo(State.FAILED); + verify(investmentCurrencyService, never()).upsertCurrencies(any()); + verify(assetUniverseService, never()).upsertMarket(any()); + verify(assetUniverseService, never()).upsertMarketSpecialDay(any()); + verify(assetUniverseService, never()).upsertAssetCategoryType(any()); + verify(assetUniverseService, never()).upsertAssetCategory(any()); + verify(assetUniverseService, never()).createAssets(any()); + verify(investmentAssetPriceService, never()).ingestPrices(any(), any()); + verify(investmentIntradayAssetPriceService, never()).ingestIntradayPrices(); + }) + .verifyComplete(); + } + } - List groupResults = List.of(groupResult1, groupResult2); + // ========================================================================= + // rollBack + // ========================================================================= - // Mock: Price ingestion returns GroupResult tasks - when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.just(groupResults)); + /** + * Tests for the {@code rollBack} method. + * + *

Rollback is intentionally a no-op for this saga since investment operations + * are idempotent. The method must complete without emitting any element. + */ + @Nested + @DisplayName("rollBack") + class RollBackTests { + + /** + * Verifies that {@code rollBack} returns an empty Mono and completes without error. + */ + @Test + @DisplayName("should return empty Mono and complete without error") + void rollBack_returnsEmptyMono() { + InvestmentAssetsTask task = createMinimalTask(); + + StepVerifier.create(saga.rollBack(task)) + .verifyComplete(); + } + } - // When: Execute upsertPrices - Mono result = saga.executeTask(task); + // ========================================================================= + // upsertCurrencies + // ========================================================================= - // Then: Verify successful completion - StepVerifier.create(result) - .assertNext(completedTask -> { - assertThat(completedTask).isNotNull(); - assertThat(completedTask.getId()).isEqualTo(task.getId()); - }) - .verifyComplete(); + /** + * Tests for the {@code upsertCurrencies} stage of the saga pipeline. + * + *

Currencies are the first stage. An empty list must short-circuit without + * calling the currency service, while a populated list must delegate to + * {@link InvestmentCurrencyService#upsertCurrencies}. + */ + @Nested + @DisplayName("upsertCurrencies") + class UpsertCurrenciesTests { + + /** + * Verifies that when the currency list is empty, the pipeline short-circuits + * without calling the currency service, and the task completes successfully. + */ + @Test + @DisplayName("should complete successfully without calling service when currency list is empty") + void upsertCurrencies_emptyList_completesSuccessfully() { + InvestmentAssetsTask task = createMinimalTask(); + wireTrivialPipelineAfterCurrencies(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentCurrencyService, never()).upsertCurrencies(any()); + } + + /** + * Verifies that when the currency list is non-empty, currencies are upserted + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should upsert currencies and mark task COMPLETED") + void upsertCurrencies_success() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(List.of(buildCurrency("USD"))) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("currency-task", data); + wireTrivialPipelineAfterCurrencies(); + + when(investmentCurrencyService.upsertCurrencies(anyList())) + .thenReturn(Mono.just(List.of( + new com.backbase.investment.api.service.v1.model.Currency() + .code("USD").name("US Dollar")))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that a failure in the currency service causes the task to be marked + * {@link State#FAILED} without propagating the error signal. + */ + @Test + @DisplayName("should mark task FAILED when currency upsert throws an error") + void upsertCurrencies_error_marksTaskFailed() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(List.of(buildCurrency("USD"))) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("currency-error-task", data); + + when(investmentCurrencyService.upsertCurrencies(anyList())) + .thenReturn(Mono.error(new RuntimeException("Currency failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } - // Verify: Price ingestion was called - verify(investmentAssetPriceService, times(1)) - .ingestPrices(task.getData().getAssets(), task.getData().getPriceByAsset()); + // ========================================================================= + // upsertMarkets + // ========================================================================= + /** + * Tests for the {@code upsertMarkets} stage of the saga pipeline. + * + *

Markets follow currencies. An empty market list must skip service calls, + * while a populated list must call {@link InvestmentAssetUniverseService#upsertMarket} + * for each entry. + */ + @Nested + @DisplayName("upsertMarkets") + class UpsertMarketsTests { + + /** + * Verifies that an empty market list is handled without calling the market service. + */ + @Test + @DisplayName("should complete successfully without calling service when market list is empty") + void upsertMarkets_emptyList_completesSuccessfully() { + InvestmentAssetsTask task = createMinimalTask(); + wireTrivialPipelineAfterMarkets(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseService, never()).upsertMarket(any()); + } + + /** + * Verifies that when the market list is non-empty, markets are upserted and + * the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should upsert markets and mark task COMPLETED") + void upsertMarkets_success() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(List.of(buildMarket("NYSE"))) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("market-task", data); + wireTrivialPipelineAfterMarkets(); + + when(assetUniverseService.upsertMarket(any())) + .thenReturn(Mono.just(new com.backbase.investment.api.service.v1.model.Market() + .code("NYSE").name("NYSE Exchange"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that a market upsert error causes the task to be marked {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when market upsert fails") + void upsertMarkets_error_marksTaskFailed() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(List.of(buildMarket("NYSE"))) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("market-error-task", data); + + when(assetUniverseService.upsertMarket(any())) + .thenReturn(Mono.error(new RuntimeException("Market service failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } } + // ========================================================================= + // upsertMarketSpecialDays + // ========================================================================= /** - * Test price upsert with polling when tasks transition from PENDING to COMPLETED. + * Tests for the {@code upsertMarketSpecialDays} stage of the saga pipeline. * - *

Verifies that: - *

    - *
  • The polling mechanism waits for PENDING tasks to complete
  • - *
  • Status is checked multiple times until all tasks are non-PENDING
  • - *
  • The method completes successfully after tasks transition to COMPLETED
  • - *
+ *

Market special days follow markets. Each entry is forwarded individually to + * {@link InvestmentAssetUniverseService#upsertMarketSpecialDay}. */ - @Test - void upsertPrices_success_withPolling() { - // Given: An investment task with assets and prices - InvestmentAssetsTask task = createTestTask(); - UUID groupResultUuid = UUID.randomUUID(); - - // Mock: Asset universe service methods - when(assetUniverseService.createAssets(anyList())) - .thenReturn(Flux.fromIterable(task.getData().getAssets())); + @Nested + @DisplayName("upsertMarketSpecialDays") + class UpsertMarketSpecialDaysTests { + + /** + * Verifies that when the special-day list is non-empty, each entry is upserted + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should upsert market special days and mark task COMPLETED") + void upsertMarketSpecialDays_success() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(List.of(buildMarketSpecialDay("NYSE"))) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("special-day-task", data); + wireTrivialPipelineAfterSpecialDays(); + + when(assetUniverseService.upsertMarketSpecialDay(any())) + .thenReturn(Mono.just(new com.backbase.investment.api.service.v1.model.MarketSpecialDay() + .market("NYSE").description("Christmas"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that an empty special-day list is handled without calling the service. + */ + @Test + @DisplayName("should skip special day upsert when list is empty") + void upsertMarketSpecialDays_emptyList_skipsService() { + InvestmentAssetsTask task = createMinimalTask(); + wireTrivialPipelineAfterSpecialDays(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseService, never()).upsertMarketSpecialDay(any()); + } + + /** + * Verifies that a special-day upsert failure causes the task to be marked {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when special day upsert fails") + void upsertMarketSpecialDays_error_marksTaskFailed() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(List.of(buildMarketSpecialDay("NYSE"))) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("special-day-error-task", data); + + when(assetUniverseService.upsertMarketSpecialDay(any())) + .thenReturn(Mono.error(new RuntimeException("Special day failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } - // Mock GroupResult object returned from price ingestion - GroupResult groupResult = mock(GroupResult.class); - when(groupResult.getUuid()).thenReturn(groupResultUuid); - List groupResults = List.of(groupResult); + // ========================================================================= + // upsertAssetCategoryTypes + // ========================================================================= - // Mock: Price ingestion returns GroupResult task - when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.just(groupResults)); - - // When: Execute upsertPrices - Mono result = saga.executeTask(task); - - // Then: Verify successful completion after polling - StepVerifier.create(result) - .assertNext(completedTask -> { - assertThat(completedTask).isNotNull(); - assertThat(completedTask.getId()).isEqualTo(task.getId()); - }) - .verifyComplete(); - - // Verify: Price ingestion was called once - verify(investmentAssetPriceService, times(1)) - .ingestPrices(task.getData().getAssets(), task.getData().getPriceByAsset()); + /** + * Tests for the {@code upsertAssetCategoryTypes} stage of the saga pipeline. + * + *

Category types follow market special days. Each entry is forwarded to + * {@link InvestmentAssetUniverseService#upsertAssetCategoryType}. + */ + @Nested + @DisplayName("upsertAssetCategoryTypes") + class UpsertAssetCategoryTypesTests { + + /** + * Verifies that when the category-type list is non-empty, each entry is upserted + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should upsert asset category types and mark task COMPLETED") + void upsertAssetCategoryTypes_success() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(List.of(buildAssetCategoryType("EQ"))) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("cat-type-task", data); + wireTrivialPipelineAfterCategoryTypes(); + + when(assetUniverseService.upsertAssetCategoryType(any())) + .thenReturn(Mono.just( + new com.backbase.investment.api.service.v1.model.AssetCategoryType() + .code("EQ").name("EQ Type"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that an empty category-type list is handled without calling the service. + */ + @Test + @DisplayName("should skip category type upsert when list is empty") + void upsertAssetCategoryTypes_emptyList_skipsService() { + InvestmentAssetsTask task = createMinimalTask(); + wireTrivialPipelineAfterCategoryTypes(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseService, never()).upsertAssetCategoryType(any()); + } + + /** + * Verifies that a category-type upsert failure causes the task to be marked {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when category type upsert fails") + void upsertAssetCategoryTypes_error_marksTaskFailed() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(List.of(buildAssetCategoryType("EQ"))) + .assetCategories(Collections.emptyList()) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("cat-type-error-task", data); + + when(assetUniverseService.upsertAssetCategoryType(any())) + .thenReturn(Mono.error(new RuntimeException("Category type failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } } + // ========================================================================= + // upsertAssetCategories + // ========================================================================= + /** - * Test price upsert timeout when tasks remain PENDING beyond the timeout threshold. + * Tests for the {@code upsertAssetCategories} stage of the saga pipeline. * - *

Verifies that: - *

    - *
  • The polling mechanism respects the 5-minute timeout
  • - *
  • Timeout error is logged and handled gracefully
  • - *
  • The task is still returned despite timeout (error recovery)
  • - *
+ *

Asset categories follow category types. Each entry is forwarded to + * {@link InvestmentAssetUniverseService#upsertAssetCategory}. + * Note: the service returns the {@code sync.v1} model {@code AssetCategory} + * as defined by the asset universe service contract. */ - @Test - void upsertPrices_timeout_whenTasksRemainPending() { - // Given: An investment task with assets and prices - InvestmentAssetsTask task = createTestTask(); - UUID groupResultUuid = UUID.randomUUID(); - - // Mock: Asset universe service methods - when(assetUniverseService.createAssets(anyList())) - .thenReturn(Flux.fromIterable(task.getData().getAssets())); + @Nested + @DisplayName("upsertAssetCategories") + class UpsertAssetCategoriesTests { + + /** + * Verifies that when the category list is non-empty, each entry is upserted + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should upsert asset categories and mark task COMPLETED") + void upsertAssetCategories_success() { + AssetCategoryEntry categoryEntry = new AssetCategoryEntry(); + categoryEntry.setName("TECH"); + + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(List.of(categoryEntry)) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("cat-task", data); + wireTrivialPipelineAfterCategories(); + + // upsertAssetCategory returns the sync.v1 AssetCategory as defined by the service contract + when(assetUniverseService.upsertAssetCategory(any())) + .thenReturn(Mono.just( + new com.backbase.investment.api.service.sync.v1.model.AssetCategory() + .name("TECH"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that a category upsert failure causes the task to be marked {@link State#FAILED}. + */ + @Test + @DisplayName("should propagate error and mark task FAILED on category upsert failure") + void upsertAssetCategories_error_marksTaskFailed() { + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setName("TECH"); + + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(List.of(entry)) + .assets(Collections.emptyList()) + .assetPrices(Collections.emptyList()) + .build(); + InvestmentAssetsTask task = new InvestmentAssetsTask("cat-error-task", data); + + when(assetUniverseService.upsertAssetCategory(any())) + .thenReturn(Mono.error(new RuntimeException("Category failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } - // Mock GroupResult object returned from price ingestion - GroupResult groupResult = mock(GroupResult.class); - when(groupResult.getUuid()).thenReturn(groupResultUuid); - List groupResults = List.of(groupResult); + // ========================================================================= + // createAssets + // ========================================================================= - // Mock: Price ingestion returns GroupResult task - when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.just(groupResults)); - - // Mock: Status polling always returns PENDING (simulating stuck task) - GroupResult pending = mock(GroupResult.class); - when(pending.getUuid()).thenReturn(groupResultUuid); - when(pending.getStatus()).thenReturn("PENDING"); - - // When: Execute upsertPrices with virtual time for testing - Mono result = saga.executeTask(task); - - // Then: Verify timeout is handled and task is still returned - StepVerifier.withVirtualTime(() -> result) - .expectSubscription() - .thenAwait(Duration.ofMinutes(6)) // Wait beyond the 5-minute timeout - .assertNext(completedTask -> { - assertThat(completedTask).isNotNull(); - assertThat(completedTask.getId()).isEqualTo(task.getId()); - }) - .verifyComplete(); - - // Verify: Price ingestion was called - verify(investmentAssetPriceService, times(1)) - .ingestPrices(task.getData().getAssets(), task.getData().getPriceByAsset()); + /** + * Tests for the {@code createAssets} stage of the saga pipeline. + * + *

The stage follows asset categories. An empty asset list must short-circuit without + * calling {@link InvestmentAssetUniverseService#createAssets}, while a non-empty list + * must delegate to the service and store the resulting assets on the task. + */ + @Nested + @DisplayName("createAssets") + class CreateAssetsTests { + + /** + * Verifies that when the asset list is empty, the saga skips asset creation + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should skip asset creation and set COMPLETED when asset list is empty") + void createAssets_emptyList_setsCompleted() { + InvestmentAssetsTask task = createMinimalTask(); + + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseService, never()).createAssets(anyList()); + } + + /** + * Verifies that when assets are present, {@code createAssets} is invoked and + * the task completes with {@link State#COMPLETED}. + */ + @Test + @DisplayName("should create assets and set them on the task on success") + void createAssets_success() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that a failure in {@code createAssets} causes the task to be marked + * {@link State#FAILED} without propagating an error signal. + */ + @Test + @DisplayName("should propagate error and mark task FAILED when asset creation fails") + void createAssets_error_marksTaskFailed() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.error(new RuntimeException("Asset creation failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } } + // ========================================================================= + // upsertPrices + // ========================================================================= + /** - * Test price upsert when ingestion fails with an error. + * Tests for the {@code upsertPrices} stage of the saga pipeline. * - *

Verifies that: - *

    - *
  • Errors during price ingestion are propagated correctly
  • - *
  • The saga error handler logs the failure
  • - *
  • The task state is set to FAILED
  • - *
+ *

The stage delegates to {@link InvestmentAssetPriceService#ingestPrices} with the + * asset list and the price-by-asset map from the task data. The returned + * {@link GroupResult} list is stored on the task for use by the next stage. */ - @Test - void upsertPrices_error_duringIngestion() { - // Given: An investment task with assets and prices - InvestmentAssetsTask task = createTestTask(); + @Nested + @DisplayName("upsertPrices") + class UpsertPricesTests { + + /** + * Verifies that prices are ingested and the {@link GroupResult} list is stored on the task. + */ + @Test + @DisplayName("should ingest prices and store GroupResult list on the task") + void upsertPrices_success_immediateCompletion() { + InvestmentAssetsTask task = createTaskWithAssets(); + GroupResult groupResult = new GroupResult(UUID.randomUUID(), "PENDING", null); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(List.of(groupResult))); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(List.of(groupResult))); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that an empty {@link GroupResult} list from the price service is + * handled without error. + */ + @Test + @DisplayName("should handle empty GroupResult list from price ingestion") + void upsertPrices_emptyGroupResults() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that a price ingestion error causes the task to be marked {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when price ingestion returns error") + void upsertPrices_error_marksTaskFailed() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.error(new RuntimeException("Price ingestion failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that when the asset list is empty, the price service is still called + * (no short-circuit in upsertPrices) and completes successfully. + */ + @Test + @DisplayName("should pass empty asset list to price service when assets are empty") + void upsertPrices_emptyAssets_callsPriceServiceWithEmptyList() { + InvestmentAssetsTask task = createMinimalTask(); + + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + } - // Mock: Markets and market special days creation succeed - when(assetUniverseService.upsertMarket(any())).thenReturn(Mono.empty()); - when(assetUniverseService.upsertMarketSpecialDay(any())).thenReturn(Mono.empty()); - when(assetUniverseService.createAssets(anyList())).thenReturn(Flux.fromIterable(task.getData().getAssets())); + // ========================================================================= + // createIntradayPrices + // ========================================================================= - // Mock: Price ingestion fails with an exception - when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.error(new RuntimeException("Price ingestion failed"))); - - // When: Execute the task - Mono result = saga.executeTask(task); - - // Then: Verify error is handled and task is returned - StepVerifier.create(result) - .assertNext(failedTask -> { - assertThat(failedTask).isNotNull(); - assertThat(failedTask.getId()).isEqualTo(task.getId()); - // The error handler sets state to FAILED - }) - .verifyComplete(); - - // Verify: Price ingestion was attempted - verify(investmentAssetPriceService, times(1)) - .ingestPrices(task.getData().getAssets(), task.getData().getPriceByAsset()); + /** + * Tests for the {@code createIntradayPrices} stage of the saga pipeline. + * + *

This is the final stage. It first waits for all async price tasks to finish + * ({@link AsyncTaskService#checkPriceAsyncTasksFinished}) before calling + * {@link InvestmentIntradayAssetPriceService#ingestIntradayPrices}. + * Results are collected and stored as {@code intradayPriceTasks} on the task. + */ + @Nested + @DisplayName("createIntradayPrices") + class CreateIntradayPricesTests { + + /** + * Verifies that intraday prices are ingested after the async task check completes + * and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should ingest intraday prices after async tasks finish") + void createIntradayPrices_success() { + InvestmentAssetsTask task = createTaskWithAssets(); + GroupResult groupResult = new GroupResult(UUID.randomUUID(), "SUCCESS", null); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(List.of(groupResult))); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(List.of(groupResult))); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(List.of(groupResult))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + /** + * Verifies that an intraday price ingestion failure causes the task to be marked + * {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when intraday price ingestion fails") + void createIntradayPrices_error_marksTaskFailed() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.error(new RuntimeException("Intraday price failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that a failure in the async task check propagates as {@link State#FAILED}. + */ + @Test + @DisplayName("should mark task FAILED when async task check fails") + void createIntradayPrices_asyncCheckFails_marksTaskFailed() { + InvestmentAssetsTask task = createTaskWithAssets(); + + when(assetUniverseService.createAssets(anyList())) + .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.error(new RuntimeException("Async check failure"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that an empty intraday result list is handled gracefully. + */ + @Test + @DisplayName("should complete with empty intraday result list") + void createIntradayPrices_emptyResults_success() { + InvestmentAssetsTask task = createMinimalTask(); + + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } } + // ========================================================================= + // Helper / Builder Methods + // ========================================================================= + /** - * Test price upsert when no assets are provided. + * Creates an {@link InvestmentAssetsTask} with all data collections set to empty lists. + * Suitable as a base task when the behaviour under test is not affected by data content, + * or when testing empty-list short-circuit paths. * - *

Verifies that: - *

    - *
  • Empty asset list is handled gracefully
  • - *
  • Price ingestion is still called (service handles empty list)
  • - *
  • The task completes successfully
  • - *
+ * @return a minimal task with empty data */ - @Test - void upsertPrices_success_emptyAssets() { - // Given: An investment task with no assets - InvestmentAssetData emptyData = InvestmentAssetData.builder() + private InvestmentAssetsTask createMinimalTask() { + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) .markets(Collections.emptyList()) .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) .assets(Collections.emptyList()) .assetPrices(Collections.emptyList()) .build(); - InvestmentAssetsTask task = new InvestmentAssetsTask("test-empty-assets", emptyData); + return new InvestmentAssetsTask("minimal-task", data); + } - // Mock: Price ingestion returns empty result - when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.just(Collections.emptyList())); + /** + * Creates an {@link InvestmentAssetsTask} containing two sample {@link Asset} objects + * and corresponding {@link AssetPrice} entries. Used by asset-creation and price tests. + * + * @return a task with two assets and two prices + */ + private InvestmentAssetsTask createTaskWithAssets() { + Asset asset1 = new Asset(); + asset1.setUuid(UUID.randomUUID()); + asset1.setName("Apple Inc."); + asset1.setIsin("US0378331005"); + asset1.setTicker("AAPL"); + asset1.setMarket("NASDAQ"); + asset1.setCurrency("USD"); + asset1.setAssetType(AssetTypeEnum.STOCK); + + Asset asset2 = new Asset(); + asset2.setUuid(UUID.randomUUID()); + asset2.setName("Microsoft Corp."); + asset2.setIsin("US5949181045"); + asset2.setTicker("MSFT"); + asset2.setMarket("NYSE"); + asset2.setCurrency("USD"); + asset2.setAssetType(AssetTypeEnum.STOCK); + + AssetPrice price1 = new AssetPrice("US0378331005", "NASDAQ", "USD", 150.0, + new RandomParam(0.99, 1.01)); + AssetPrice price2 = new AssetPrice("US5949181045", "NYSE", "USD", 200.0, + new RandomParam(0.99, 1.01)); - // When: Execute upsertPrices - Mono result = saga.executeTask(task); + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(Collections.emptyList()) + .markets(Collections.emptyList()) + .marketSpecialDays(Collections.emptyList()) + .assetCategoryTypes(Collections.emptyList()) + .assetCategories(Collections.emptyList()) + .assets(List.of(asset1, asset2)) + .assetPrices(List.of(price1, price2)) + .build(); + return new InvestmentAssetsTask("assets-task", data); + } - // Then: Verify successful completion - StepVerifier.create(result) - .assertNext(completedTask -> { - assertThat(completedTask).isNotNull(); - assertThat(completedTask.getId()).isEqualTo(task.getId()); - }) - .verifyComplete(); + /** + * Creates an {@link InvestmentAssetsTask} with one entry in every data collection. + * Used for the full end-to-end happy-path test in conjunction with + * {@link #stubAllServicesSuccess(InvestmentAssetsTask)}. + * + * @return a fully populated task + */ + private InvestmentAssetsTask createFullTask() { + Asset asset1 = new Asset(); + asset1.setUuid(UUID.randomUUID()); + asset1.setName("Apple Inc."); + asset1.setIsin("US0378331005"); + asset1.setTicker("AAPL"); + asset1.setMarket("NASDAQ"); + asset1.setCurrency("USD"); + asset1.setAssetType(AssetTypeEnum.STOCK); + AssetPrice price = new AssetPrice("US0378331005", "NASDAQ", "USD", 150.0, + new RandomParam(0.99, 1.01)); + + AssetCategoryEntry categoryEntry = new AssetCategoryEntry(); + categoryEntry.setName("TECH"); - // Verify: Price ingestion was called with empty list - verify(investmentAssetPriceService, times(1)) - .ingestPrices(Collections.emptyList(), emptyData.getPriceByAsset()); + InvestmentAssetData data = InvestmentAssetData.builder() + .currencies(List.of(buildCurrency("USD"))) + .markets(List.of(buildMarket("NYSE"))) + .marketSpecialDays(List.of(buildMarketSpecialDay("NYSE"))) + .assetCategoryTypes(List.of(buildAssetCategoryType("EQ"))) + .assetCategories(List.of(categoryEntry)) + .assets(List.of(asset1)) + .assetPrices(List.of(price)) + .build(); + return new InvestmentAssetsTask("full-task", data); } /** - * Test price upsert with mixed GroupResult statuses. + * Configures all mocked services to return successful responses for a fully populated task. + * Intended for use with {@link #createFullTask()} in end-to-end happy-path tests. * - *

Verifies that: - *

    - *
  • Polling continues until ALL tasks are non-PENDING
  • - *
  • Different task statuses (COMPLETED, FAILED) are handled
  • - *
  • The method completes when no tasks are PENDING
  • - *
+ * @param task the task whose asset list is used to stub {@code createAssets} */ - @Test - void upsertPrices_success_mixedStatuses() { - // Given: An investment task with assets and prices - InvestmentAssetsTask task = createTestTask(); - UUID groupResultUuid1 = UUID.randomUUID(); - UUID groupResultUuid2 = UUID.randomUUID(); - UUID groupResultUuid3 = UUID.randomUUID(); - - // Mock: Asset universe service methods + private void stubAllServicesSuccess(InvestmentAssetsTask task) { + when(investmentCurrencyService.upsertCurrencies(anyList())) + .thenReturn(Mono.just(Collections.emptyList())); + when(assetUniverseService.upsertMarket(any())) + .thenReturn(Mono.just(new com.backbase.investment.api.service.v1.model.Market() + .code("NYSE").name("NYSE Exchange"))); + when(assetUniverseService.upsertMarketSpecialDay(any())) + .thenReturn(Mono.just(new com.backbase.investment.api.service.v1.model.MarketSpecialDay() + .market("NYSE").description("Christmas"))); + when(assetUniverseService.upsertAssetCategoryType(any())) + .thenReturn(Mono.just( + new com.backbase.investment.api.service.v1.model.AssetCategoryType() + .code("EQ").name("EQ Type"))); + // upsertAssetCategory returns the sync.v1 AssetCategory as defined by the service contract + when(assetUniverseService.upsertAssetCategory(any())) + .thenReturn(Mono.just( + new com.backbase.investment.api.service.sync.v1.model.AssetCategory() + .name("TECH"))); when(assetUniverseService.createAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); + when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); + } - // Mock GroupResult objects returned from price ingestion - GroupResult groupResult1 = mock(GroupResult.class); - when(groupResult1.getUuid()).thenReturn(groupResultUuid1); - - GroupResult groupResult2 = mock(GroupResult.class); - when(groupResult2.getUuid()).thenReturn(groupResultUuid2); - - GroupResult groupResult3 = mock(GroupResult.class); - when(groupResult3.getUuid()).thenReturn(groupResultUuid3); - - List groupResults = List.of(groupResult1, groupResult2, groupResult3); - - // Mock: Price ingestion returns GroupResult tasks + /** + * Stubs the price and intraday-price stages so that tests focusing on earlier pipeline + * stages (currencies, markets, special days, category types, categories) can complete + * without needing to configure those downstream mocks individually. + * + *

Note: upstream stages that short-circuit on empty lists (currencies, markets, + * special days, category types, categories) do NOT need stubs when using + * {@link #createMinimalTask()} — the implementation returns early without calling services. + */ + private void wireTrivialPipelineAfterCurrencies() { when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) - .thenReturn(Mono.just(groupResults)); - - // When: Execute upsertPrices - Mono result = saga.executeTask(task); - - // Then: Verify successful completion after all tasks are non-PENDING - StepVerifier.create(result) - .assertNext(completedTask -> { - assertThat(completedTask).isNotNull(); - assertThat(completedTask.getId()).isEqualTo(task.getId()); - }) - .verifyComplete(); - - // Verify: Price ingestion was called - verify(investmentAssetPriceService, times(1)) - .ingestPrices(task.getData().getAssets(), task.getData().getPriceByAsset()); + .thenReturn(Mono.just(Collections.emptyList())); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.just(Collections.emptyList())); + when(investmentIntradayAssetPriceService.ingestIntradayPrices()) + .thenReturn(Mono.just(Collections.emptyList())); } - // Helper methods + /** + * Stubs the price and intraday-price stages for tests focused on the market stage. + * Functionally equivalent to {@link #wireTrivialPipelineAfterCurrencies()} but named + * to clarify intent at the call site. + */ + private void wireTrivialPipelineAfterMarkets() { + wireTrivialPipelineAfterCurrencies(); + } /** - * Creates a test InvestmentAssetsTask with sample data for testing. - * - * @return a configured test task + * Stubs the price and intraday-price stages for tests focused on the market special days stage. */ - private InvestmentAssetsTask createTestTask() { - // Create sample assets using the Asset constructor - Asset asset1 = new Asset( - UUID.randomUUID(), - "Apple Inc.", - "US0378331005", - "AAPL", - StatusA10Enum.ACTIVE, - "NASDAQ", - "USD", - Collections.emptyMap(), - AssetTypeEnum.STOCK, - List.of("Technology"), - "AAPL-001", - null, - null, - "Apple Inc. Stock", - 150.0 - ); + private void wireTrivialPipelineAfterSpecialDays() { + wireTrivialPipelineAfterCurrencies(); + } - Asset asset2 = new Asset( - UUID.randomUUID(), - "Microsoft Corp.", - "US5949181045", - "MSFT", - StatusA10Enum.ACTIVE, - "NYSE", - "USD", - Collections.emptyMap(), - AssetTypeEnum.STOCK, - List.of("Technology"), - "MSFT-001", - null, - null, - "Microsoft Corp. Stock", - 200.0 - ); + /** + * Stubs the price and intraday-price stages for tests focused on the asset category types stage. + */ + private void wireTrivialPipelineAfterCategoryTypes() { + wireTrivialPipelineAfterCurrencies(); + } - List assets = List.of(asset1, asset2); + /** + * Stubs the price and intraday-price stages for tests focused on the asset categories stage. + */ + private void wireTrivialPipelineAfterCategories() { + wireTrivialPipelineAfterCurrencies(); + } - // Create sample price list using the record constructor - AssetPrice price1 = new AssetPrice( - "US0378331005", - "NASDAQ", - "USD", - 150.0, - new RandomParam(0.99, 1.01) - ); + // --- Domain object builders --- - AssetPrice price2 = new AssetPrice( - "US5949181045", - "NYSE", - "USD", - 200.0, - new RandomParam(0.99, 1.01) - ); + /** + * Builds a {@link Currency} domain object with the given ISO code. + * + * @param code ISO 4217 currency code, e.g. {@code "USD"} + * @return a populated {@link Currency} + */ + private Currency buildCurrency(String code) { + return new Currency().code(code).name(code + " Currency"); + } - List assetPrices = List.of(price1, price2); + /** + * Builds a {@link Market} domain object representing an exchange with the given MIC code. + * + * @param code market identification code, e.g. {@code "NYSE"} + * @return a populated {@link Market} with session hours set to 09:00–17:00 UTC + */ + private Market buildMarket(String code) { + return new Market() + .code(code) + .name(code + " Exchange") + .sessionStart(String.valueOf(LocalTime.of(9, 0))) + .sessionEnd(String.valueOf(LocalTime.of(17, 0))); + } - // Build the investment asset data - InvestmentAssetData data = InvestmentAssetData.builder() - .markets(Collections.emptyList()) - .marketSpecialDays(Collections.emptyList()) - .assets(assets) - .assetPrices(assetPrices) - .build(); + /** + * Builds a {@link MarketSpecialDay} domain object for Christmas day on the given market. + * + * @param market the market code to associate with the special day + * @return a {@link MarketSpecialDay} for 25 December 2025 + */ + private MarketSpecialDay buildMarketSpecialDay(String market) { + return new MarketSpecialDay() + .market(market) + .date(LocalDate.of(2025, 12, 25)) + .description("Christmas"); + } - return new InvestmentAssetsTask("test-unit-of-work", data); + /** + * Builds an {@link AssetCategoryType} domain object with the given classification code. + * + * @param code asset category type code, e.g. {@code "EQ"} for equity + * @return a populated {@link AssetCategoryType} + */ + private AssetCategoryType buildAssetCategoryType(String code) { + return new AssetCategoryType().code(code).name(code + " Type"); } } - diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentContentSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentContentSagaTest.java new file mode 100644 index 000000000..f3bd90511 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentContentSagaTest.java @@ -0,0 +1,809 @@ +package com.backbase.stream.investment.saga; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; +import com.backbase.stream.investment.InvestmentContentData; +import com.backbase.stream.investment.InvestmentContentTask; +import com.backbase.stream.investment.model.ContentDocumentEntry; +import com.backbase.stream.investment.model.ContentTag; +import com.backbase.stream.investment.model.MarketNewsEntry; +import com.backbase.stream.investment.service.resttemplate.InvestmentRestDocumentContentService; +import com.backbase.stream.investment.service.resttemplate.InvestmentRestNewsContentService; +import com.backbase.stream.worker.model.StreamTask.State; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit test suite for {@link InvestmentContentSaga}. + * + *

This class verifies the complete orchestration logic of the saga, which drives + * the investment content ingestion pipeline through the following stages: + *

    + *
  1. Upsert news tags
  2. + *
  3. Upsert news content
  4. + *
  5. Upsert document tags
  6. + *
  7. Upsert content documents
  8. + *
+ * + *

Test strategy: + *

    + *
  • Each pipeline stage is tested in isolation via a dedicated {@code @Nested} class.
  • + *
  • Happy-path, empty-collection, null-field, and error scenarios are covered for every stage.
  • + *
  • {@code wireTrivialPipelineAfter*()} helpers stub downstream stages so that each + * nested class can focus solely on its own stage under test.
  • + *
  • Error recovery is verified via the saga's {@code onErrorResume} handler, which + * always emits the task with {@link State#FAILED} instead of propagating the error + * signal — therefore {@link StepVerifier#verifyComplete()} is always used, never + * {@code verifyError()}.
  • + *
  • All reactive assertions use Project Reactor's {@link StepVerifier}.
  • + *
+ * + *

Mocked dependencies: + *

    + *
  • {@link InvestmentRestNewsContentService} – news tag and news content upsert
  • + *
  • {@link InvestmentRestDocumentContentService} – document tag and document upsert
  • + *
  • {@link InvestmentIngestionConfigurationProperties} – feature flag
  • + *
+ */ +class InvestmentContentSagaTest { + + @Mock + private InvestmentRestNewsContentService investmentRestNewsContentService; + + @Mock + private InvestmentRestDocumentContentService investmentRestDocumentContentService; + + @Mock + private InvestmentIngestionConfigurationProperties configurationProperties; + + private InvestmentContentSaga saga; + + /** + * Opens Mockito annotations and constructs the saga under test before each test. + * Content ingestion is enabled by default; individual tests may override this stub. + */ + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(configurationProperties.isContentEnabled()).thenReturn(true); + saga = new InvestmentContentSaga( + investmentRestNewsContentService, + investmentRestDocumentContentService, + configurationProperties + ); + } + + // ========================================================================= + // contentEnabled flag + // ========================================================================= + + /** + * Tests for the {@code contentEnabled} configuration flag. + * + *

When {@code false}, the saga must return the task immediately without + * invoking any downstream service. + */ + @Nested + @DisplayName("contentEnabled flag") + class ContentEnabledFlagTests { + + /** + * Verifies that when {@code contentEnabled} is {@code false}, the saga skips + * all pipeline stages and returns the task without any service calls. + */ + @Test + @DisplayName("should skip saga execution when contentEnabled is false") + void contentDisabled_skipsSagaExecution() { + when(configurationProperties.isContentEnabled()).thenReturn(false); + InvestmentContentTask task = createMinimalTask("disabled-task"); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result).isSameAs(task)) + .verifyComplete(); + + verify(investmentRestNewsContentService, never()).upsertTags(anyList()); + verify(investmentRestNewsContentService, never()).upsertContent(anyList()); + verify(investmentRestDocumentContentService, never()).upsertContentTags(anyList()); + verify(investmentRestDocumentContentService, never()).upsertDocuments(anyList()); + } + + /** + * Verifies that when {@code contentEnabled} is {@code true} the saga proceeds + * through the full pipeline and the task reaches {@link State#COMPLETED}. + * Uses a fully populated task to meaningfully exercise all pipeline stages. + */ + @Test + @DisplayName("should execute saga when contentEnabled is true") + void contentEnabled_executesSaga() { + InvestmentContentTask task = createFullTask("enabled-task"); + stubAllServicesSuccess(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + } + + // ========================================================================= + // Full pipeline happy path + // ========================================================================= + + /** + * End-to-end happy-path test covering all four pipeline stages with populated data. + * All services are stubbed to return successful responses. The task is expected + * to reach {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete full pipeline successfully when all data is present") + void executeTask_fullPipeline_success() { + InvestmentContentTask task = createFullTask("full-task"); + stubAllServicesSuccess(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + // ========================================================================= + // upsertNewsTags + // ========================================================================= + + /** + * Tests for the {@code upsertNewsTags} stage of the content saga pipeline. + * + *

News tags are the first stage. The service must be called with the tag list + * from the task data, and downstream stages are stubbed trivially so that the + * task can reach a terminal state. + */ + @Nested + @DisplayName("upsertNewsTags") + class UpsertNewsTagsTests { + + /** + * Verifies that when the news-tag list is non-empty the tag service is called + * and the task is marked {@link State#COMPLETED}. + * Also verifies that a task history entry (info) is recorded for the stage. + */ + @Test + @DisplayName("should upsert news tags and mark task COMPLETED") + void upsertNewsTags_success() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(List.of(buildContentTag("NEWS_TAG", "NewsValue"))) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("news-tags-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> { + assertThat(result.getState()).isEqualTo(State.COMPLETED); + assertThat(result.getHistory()).isNotEmpty(); + }) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertTags(anyList()); + } + + /** + * Verifies that an empty news-tag list is still forwarded to the service + * (no early-exit in the implementation) and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete successfully when news tag list is empty") + void upsertNewsTags_emptyList_completesSuccessfully() { + InvestmentContentTask task = createMinimalTask("empty-news-tags-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertTags(Collections.emptyList()); + } + + /** + * Verifies that task state transitions through IN_PROGRESS before COMPLETED. + */ + @Test + @DisplayName("should transition task state through IN_PROGRESS then COMPLETED") + void upsertNewsTags_stateTransition_inProgressThenCompleted() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(List.of(buildContentTag("NEWS_TAG", "NewsValue"))) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("news-tags-state-task", data); + AtomicReference stateAtServiceCall = new AtomicReference<>(); + + when(investmentRestNewsContentService.upsertTags(anyList())) + .thenAnswer(invocation -> { + stateAtServiceCall.set(task.getState()); + return Mono.empty(); + }); + wireTrivialPipelineAfterNewsTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(stateAtServiceCall.get()).isEqualTo(State.IN_PROGRESS); + } + + /** + * Verifies that a news-tag upsert error causes the task to be marked + * {@link State#FAILED} without propagating the error signal. + */ + @Test + @DisplayName("should mark task FAILED when news tag upsert throws an error") + void upsertNewsTags_error_marksTaskFailed() { + InvestmentContentTask task = createMinimalTask("news-tags-error-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())) + .thenReturn(Mono.error(new RuntimeException("news tag upsert failed"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that null marketNewsTags in task data falls back to empty list + * via {@code Objects.requireNonNullElse}, and the service is called with an empty list. + */ + @Test + @DisplayName("should handle null news tags field gracefully using empty list fallback") + void upsertNewsTags_nullField_fallsBackToEmptyList() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(null) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("null-news-tags-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertTags(Collections.emptyList()); + } + } + + // ========================================================================= + // upsertNewsContent + // ========================================================================= + + /** + * Tests for the {@code upsertNewsContent} stage of the content saga pipeline. + * + *

News content follows news tags. Each entry is forwarded to + * {@link InvestmentRestNewsContentService#upsertContent}. + */ + @Nested + @DisplayName("upsertNewsContent") + class UpsertNewsContentTests { + + /** + * Verifies that when the news content list is non-empty the content service is + * called and the task is marked {@link State#COMPLETED}. + * Also verifies that a task history entry (info) is recorded for the stage. + */ + @Test + @DisplayName("should upsert news content and mark task COMPLETED") + void upsertNewsContent_success() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(List.of(buildMarketNewsEntry("Market Update"))) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("news-content-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsContent(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> { + assertThat(result.getState()).isEqualTo(State.COMPLETED); + assertThat(result.getHistory()).isNotEmpty(); + }) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertContent(anyList()); + } + + /** + * Verifies that an empty news content list is still forwarded to the service + * (no early-exit in the implementation) and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete successfully when news content list is empty") + void upsertNewsContent_emptyList_completesSuccessfully() { + InvestmentContentTask task = createMinimalTask("empty-news-content-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsContent(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertContent(Collections.emptyList()); + } + + /** + * Verifies that task state transitions through IN_PROGRESS before COMPLETED. + */ + @Test + @DisplayName("should transition task state through IN_PROGRESS then COMPLETED") + void upsertNewsContent_stateTransition_inProgressThenCompleted() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(List.of(buildMarketNewsEntry("Market Update"))) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("news-content-state-task", data); + AtomicReference stateAtServiceCall = new AtomicReference<>(); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())) + .thenAnswer(invocation -> { + stateAtServiceCall.set(task.getState()); + return Mono.empty(); + }); + wireTrivialPipelineAfterNewsContent(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(stateAtServiceCall.get()).isEqualTo(State.IN_PROGRESS); + } + + /** + * Verifies that a news content upsert error causes the task to be marked + * {@link State#FAILED} without propagating the error signal. + */ + @Test + @DisplayName("should mark task FAILED when news content upsert throws an error") + void upsertNewsContent_error_marksTaskFailed() { + InvestmentContentTask task = createMinimalTask("news-content-error-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())) + .thenReturn(Mono.error(new RuntimeException("news content upsert failed"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that null marketNews in task data falls back to empty list + * via {@code Objects.requireNonNullElse}, and the service is called with an empty list. + */ + @Test + @DisplayName("should handle null news content field gracefully using empty list fallback") + void upsertNewsContent_nullField_fallsBackToEmptyList() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(null) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("null-news-content-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterNewsContent(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestNewsContentService).upsertContent(Collections.emptyList()); + } + } + + // ========================================================================= + // upsertDocumentTags + // ========================================================================= + + /** + * Tests for the {@code upsertDocumentTags} stage of the content saga pipeline. + * + *

Document tags follow news content. Each entry is forwarded to + * {@link InvestmentRestDocumentContentService#upsertContentTags}. + */ + @Nested + @DisplayName("upsertDocumentTags") + class UpsertDocumentTagsTests { + + /** + * Verifies that when the document-tag list is non-empty the document tag service + * is called and the task is marked {@link State#COMPLETED}. + * Also verifies that a task history entry (info) is recorded for the stage. + */ + @Test + @DisplayName("should upsert document tags and mark task COMPLETED") + void upsertDocumentTags_success() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(List.of(buildContentTag("DOC_TAG", "DocValue"))) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("doc-tags-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterDocumentTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> { + assertThat(result.getState()).isEqualTo(State.COMPLETED); + assertThat(result.getHistory()).isNotEmpty(); + }) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertContentTags(anyList()); + } + + /** + * Verifies that an empty document-tag list is still forwarded to the service + * (no early-exit in the implementation) and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete successfully when document tag list is empty") + void upsertDocumentTags_emptyList_completesSuccessfully() { + InvestmentContentTask task = createMinimalTask("empty-doc-tags-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterDocumentTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertContentTags(Collections.emptyList()); + } + + /** + * Verifies that task state transitions through IN_PROGRESS before COMPLETED. + */ + @Test + @DisplayName("should transition task state through IN_PROGRESS then COMPLETED") + void upsertDocumentTags_stateTransition_inProgressThenCompleted() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(List.of(buildContentTag("DOC_TAG", "DocValue"))) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("doc-tags-state-task", data); + AtomicReference stateAtServiceCall = new AtomicReference<>(); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())) + .thenAnswer(invocation -> { + stateAtServiceCall.set(task.getState()); + return Mono.empty(); + }); + wireTrivialPipelineAfterDocumentTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(stateAtServiceCall.get()).isEqualTo(State.IN_PROGRESS); + } + + /** + * Verifies that a document-tag upsert error causes the task to be marked + * {@link State#FAILED} without propagating the error signal. + */ + @Test + @DisplayName("should mark task FAILED when document tag upsert throws an error") + void upsertDocumentTags_error_marksTaskFailed() { + InvestmentContentTask task = createMinimalTask("doc-tags-error-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())) + .thenReturn(Mono.error(new RuntimeException("document tag upsert failed"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that null documentTags in task data falls back to empty list + * via {@code Objects.requireNonNullElse}, and the service is called with an empty list. + */ + @Test + @DisplayName("should handle null document tags field gracefully using empty list fallback") + void upsertDocumentTags_nullField_fallsBackToEmptyList() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(null) + .documents(Collections.emptyList()) + .build(); + InvestmentContentTask task = new InvestmentContentTask("null-doc-tags-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + wireTrivialPipelineAfterDocumentTags(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertContentTags(Collections.emptyList()); + } + } + + // ========================================================================= + // upsertContentDocuments + // ========================================================================= + + /** + * Tests for the {@code upsertContentDocuments} stage of the content saga pipeline. + * + *

This is the final stage. Document entries are forwarded to + * {@link InvestmentRestDocumentContentService#upsertDocuments}. + */ + @Nested + @DisplayName("upsertContentDocuments") + class UpsertContentDocumentsTests { + + /** + * Verifies that when the document list is non-empty the document service is called + * and the task is marked {@link State#COMPLETED}. + * Also verifies that a task history entry (info) is recorded for the stage. + */ + @Test + @DisplayName("should upsert content documents and mark task COMPLETED") + void upsertContentDocuments_success() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(List.of(buildContentDocumentEntry("Annual Report"))) + .build(); + InvestmentContentTask task = new InvestmentContentTask("docs-task", data); + stubAllServicesSuccess(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> { + assertThat(result.getState()).isEqualTo(State.COMPLETED); + assertThat(result.getHistory()).isNotEmpty(); + }) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertDocuments(anyList()); + } + + /** + * Verifies that an empty document list is still forwarded to the service + * (no early-exit in the implementation) and the task is marked {@link State#COMPLETED}. + */ + @Test + @DisplayName("should complete successfully when document list is empty") + void upsertContentDocuments_emptyList_completesSuccessfully() { + InvestmentContentTask task = createMinimalTask("empty-docs-task"); + stubAllServicesSuccess(); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertDocuments(Collections.emptyList()); + } + + /** + * Verifies that task state transitions through IN_PROGRESS before COMPLETED. + */ + @Test + @DisplayName("should transition task state through IN_PROGRESS then COMPLETED") + void upsertContentDocuments_stateTransition_inProgressThenCompleted() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(List.of(buildContentDocumentEntry("Annual Report"))) + .build(); + InvestmentContentTask task = new InvestmentContentTask("docs-state-task", data); + AtomicReference stateAtServiceCall = new AtomicReference<>(); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())) + .thenAnswer(invocation -> { + stateAtServiceCall.set(task.getState()); + return Mono.empty(); + }); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(stateAtServiceCall.get()).isEqualTo(State.IN_PROGRESS); + } + + /** + * Verifies that a document upsert error causes the task to be marked + * {@link State#FAILED} without propagating the error signal. + */ + @Test + @DisplayName("should mark task FAILED when document upsert throws an error") + void upsertContentDocuments_error_marksTaskFailed() { + InvestmentContentTask task = createMinimalTask("docs-error-task"); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())) + .thenReturn(Mono.error(new RuntimeException("document upsert failed"))); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + /** + * Verifies that null documents in task data falls back to empty list + * via {@code Objects.requireNonNullElse}, and the service is called with an empty list. + */ + @Test + @DisplayName("should handle null documents field gracefully using empty list fallback") + void upsertContentDocuments_nullField_fallsBackToEmptyList() { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(null) + .build(); + InvestmentContentTask task = new InvestmentContentTask("null-docs-task", data); + + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())).thenReturn(Mono.empty()); + + StepVerifier.create(saga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentRestDocumentContentService).upsertDocuments(Collections.emptyList()); + } + } + + // ========================================================================= + // rollBack + // ========================================================================= + + /** + * Tests for the {@link InvestmentContentSaga#rollBack} operation. + * + *

Rollback is intentionally not implemented for the content saga because all + * operations are idempotent. The method must return an empty {@link Mono}. + */ + @Nested + @DisplayName("rollBack") + class RollBackTests { + + /** + * Verifies that {@code rollBack} completes empty without invoking any service. + */ + @Test + @DisplayName("should return empty Mono without calling any service") + void rollBack_returnsEmptyMono() { + InvestmentContentTask task = createMinimalTask("rollback-task"); + + StepVerifier.create(saga.rollBack(task)) + .verifyComplete(); + + verify(investmentRestNewsContentService, never()).upsertTags(anyList()); + verify(investmentRestNewsContentService, never()).upsertContent(anyList()); + verify(investmentRestDocumentContentService, never()).upsertContentTags(anyList()); + verify(investmentRestDocumentContentService, never()).upsertDocuments(anyList()); + } + } + + // ========================================================================= + // Helper / Builder Methods + // ========================================================================= + + private InvestmentContentTask createMinimalTask(String taskId) { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(Collections.emptyList()) + .marketNews(Collections.emptyList()) + .documentTags(Collections.emptyList()) + .documents(Collections.emptyList()) + .build(); + return new InvestmentContentTask(taskId, data); + } + + private InvestmentContentTask createFullTask(String taskId) { + InvestmentContentData data = InvestmentContentData.builder() + .marketNewsTags(List.of(buildContentTag("NEWS_TAG", "NewsValue"))) + .marketNews(List.of(buildMarketNewsEntry("Market Update"))) + .documentTags(List.of(buildContentTag("DOC_TAG", "DocValue"))) + .documents(List.of(buildContentDocumentEntry("Annual Report"))) + .build(); + return new InvestmentContentTask(taskId, data); + } + + private void stubAllServicesSuccess() { + when(investmentRestNewsContentService.upsertTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())).thenReturn(Mono.empty()); + } + + private void wireTrivialPipelineAfterNewsTags() { + when(investmentRestNewsContentService.upsertContent(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())).thenReturn(Mono.empty()); + } + + private void wireTrivialPipelineAfterNewsContent() { + when(investmentRestDocumentContentService.upsertContentTags(anyList())).thenReturn(Mono.empty()); + when(investmentRestDocumentContentService.upsertDocuments(anyList())).thenReturn(Mono.empty()); + } + + private void wireTrivialPipelineAfterDocumentTags() { + when(investmentRestDocumentContentService.upsertDocuments(anyList())).thenReturn(Mono.empty()); + } + + private ContentTag buildContentTag(String code, String value) { + return new ContentTag(code, value); + } + + private MarketNewsEntry buildMarketNewsEntry(String title) { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle(title); + return entry; + } + + private ContentDocumentEntry buildContentDocumentEntry(String name) { + ContentDocumentEntry entry = new ContentDocumentEntry(); + entry.setName(name); + return entry; + } +} diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java new file mode 100644 index 000000000..73a4ca55b --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentSagaTest.java @@ -0,0 +1,526 @@ +package com.backbase.stream.investment.saga; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse; +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.ProductTypeEnum; +import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; +import com.backbase.stream.investment.ClientUser; +import com.backbase.stream.investment.InvestmentArrangement; +import com.backbase.stream.investment.InvestmentData; +import com.backbase.stream.investment.InvestmentTask; +import com.backbase.stream.investment.ModelPortfolio; +import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; +import com.backbase.stream.investment.service.AsyncTaskService; +import com.backbase.stream.investment.service.InvestmentClientService; +import com.backbase.stream.investment.service.InvestmentModelPortfolioService; +import com.backbase.stream.investment.service.InvestmentPortfolioAllocationService; +import com.backbase.stream.investment.service.InvestmentPortfolioService; +import com.backbase.stream.worker.model.StreamTask.State; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit test suite for {@link InvestmentSaga}. + */ +class InvestmentSagaTest { + + private static final String SA_NAME = "some-sa-name"; + private static final String SA_EXTERNAL_ID = "some-sa-external-id"; + private static final String ARRANGEMENT_EXTERNAL_ID = "some-arrangement-id"; + private static final String PORTFOLIO_EXTERNAL_ID = "some-portfolio-external-id"; + private static final String ACCOUNT_ID = "some-account-id"; + private static final String ACCOUNT_EXTERNAL_ID = "some-account-external-id"; + private static final String LE_EXTERNAL_ID = "some-le-external-id"; + + @Mock + private InvestmentClientService clientService; + + @Mock + private InvestmentPortfolioService investmentPortfolioService; + + @Mock + private InvestmentPortfolioAllocationService investmentPortfolioAllocationService; + + @Mock + private InvestmentModelPortfolioService investmentModelPortfolioService; + + @Mock + private AsyncTaskService asyncTaskService; + + @Mock + private InvestmentIngestionConfigurationProperties configurationProperties; + + private InvestmentSaga investmentSaga; + + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + when(configurationProperties.isWealthEnabled()).thenReturn(true); + investmentSaga = new InvestmentSaga( + clientService, + investmentPortfolioService, + investmentPortfolioAllocationService, + investmentModelPortfolioService, + asyncTaskService, + configurationProperties + ); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + // ========================================================================= + // executeTask – top-level orchestration + // ========================================================================= + + @Nested + @DisplayName("executeTask") + class ExecuteTaskTests { + + @Test + @DisplayName("should complete successfully when all services succeed") + void executeTask_allServicesSucceed_completesNormally() { + InvestmentTask task = createFullTask(); + stubAllServicesSuccess(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + @Test + @DisplayName("should mark task FAILED and complete stream when a service throws an error") + void executeTask_serviceThrowsError_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.error(new RuntimeException("Model portfolio service down"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + + @Test + @DisplayName("should complete with empty task data") + void executeTask_emptyTask_completesNormally() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + } + + // ========================================================================= + // rollBack + // ========================================================================= + + @Nested + @DisplayName("rollBack") + class RollBackTests { + + @Test + @DisplayName("should return empty Mono and complete without error") + void rollBack_returnsEmptyMono() { + InvestmentTask task = createMinimalTask(); + + StepVerifier.create(investmentSaga.rollBack(task)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertPortfolioModels + // ========================================================================= + + @Nested + @DisplayName("upsertPortfolioModels") + class UpsertPortfolioModelsTests { + + @Test + @DisplayName("should complete successfully without calling service when model portfolio list is empty") + void upsertPortfolioModels_emptyList_completesSuccessfully() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentModelPortfolioService).upsertModels(any()); + } + + @Test + @DisplayName("should upsert portfolio models and mark task COMPLETED") + void upsertPortfolioModels_success() { + InvestmentTask task = createTaskWithModelPortfolios(); + wireTrivialPipelineAfterModelPortfolios(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + @Test + @DisplayName("should mark task FAILED when model portfolio upsert throws an error") + void upsertPortfolioModels_error_marksTaskFailed() { + InvestmentTask task = createTaskWithModelPortfolios(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.error(new RuntimeException("Model portfolio service failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertClients + // ========================================================================= + + @Nested + @DisplayName("upsertClients") + class UpsertClientsTests { + + @Test + @DisplayName("should complete successfully without calling service when client list is empty") + void upsertClients_emptyList_completesSuccessfully() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(clientService).upsertClients(Collections.emptyList()); + } + + @Test + @DisplayName("should upsert clients and mark task COMPLETED") + void upsertClients_success() { + InvestmentTask task = createFullTask(); + stubAllServicesSuccess(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + @Test + @DisplayName("should mark task FAILED when client upsert throws an error") + void upsertClients_error_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.error(new RuntimeException("Client service failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertArrangements + // ========================================================================= + + @Nested + @DisplayName("upsertArrangements") + class UpsertArrangementsTests { + + @Test + @DisplayName("should complete successfully without calling service when arrangement list is empty") + void upsertArrangements_emptyList_completesSuccessfully() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentPortfolioService).upsertInvestmentProducts(any(), any()); + } + + @Test + @DisplayName("should mark task FAILED when arrangement upsert throws an error") + void upsertArrangements_error_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.error(new RuntimeException("Arrangement upsert failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertPortfolios + // ========================================================================= + + @Nested + @DisplayName("upsertPortfolios") + class UpsertPortfoliosTests { + + @Test + @DisplayName("should complete successfully without calling service when portfolio list is empty") + void upsertPortfolios_emptyList_completesSuccessfully() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentPortfolioService).upsertPortfolios(any(), any()); + } + + @Test + @DisplayName("should mark task FAILED when portfolio upsert throws an error") + void upsertPortfolios_error_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioProduct()))); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.error(new RuntimeException("Portfolio upsert failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertPortfolioTradingAccounts + // ========================================================================= + + @Nested + @DisplayName("upsertPortfolioTradingAccounts") + class UpsertPortfolioTradingAccountsTests { + + @Test + @DisplayName("should complete successfully without calling service when trading account list is empty") + void upsertPortfolioTradingAccounts_emptyList_completesSuccessfully() { + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentPortfolioService).upsertPortfolioTradingAccounts(Collections.emptyList()); + } + + @Test + @DisplayName("should upsert trading accounts and mark task COMPLETED") + void upsertPortfolioTradingAccounts_success() { + InvestmentTask task = createFullTask(); + stubAllServicesSuccess(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + @Test + @DisplayName("should mark task FAILED when trading account upsert throws an error") + void upsertPortfolioTradingAccounts_error_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioProduct()))); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioList()))); + when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) + .thenReturn(Mono.error(new RuntimeException("Trading account upsert failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // upsertDepositsAndAllocations + // ========================================================================= + + @Nested + @DisplayName("upsertDepositsAndAllocations") + class UpsertDepositsAndAllocationsTests { + + @Test + @DisplayName("should upsert allocations and mark task COMPLETED") + void upsertDepositsAndAllocations_success() { + InvestmentTask task = createFullTask(); + stubAllServicesSuccess(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + } + + @Test + @DisplayName("should mark task FAILED when allocation upsert throws an error") + void upsertDepositsAndAllocations_error_marksTaskFailed() { + InvestmentTask task = createFullTask(); + + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioProduct()))); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioList()))); + when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioService.upsertDeposits(any())) + .thenReturn(Mono.empty()); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) + .thenReturn(Mono.error(new RuntimeException("Allocation upsert failure"))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.FAILED)) + .verifyComplete(); + } + } + + // ========================================================================= + // Helper / Builder Methods + // ========================================================================= + + private InvestmentTask createMinimalTask() { + return new InvestmentTask("minimal-task", InvestmentData.builder() + .saName(SA_NAME) + .saExternalId(SA_EXTERNAL_ID) + .clientUsers(Collections.emptyList()) + .investmentArrangements(Collections.emptyList()) + .modelPortfolios(Collections.emptyList()) + .investmentPortfolioTradingAccounts(Collections.emptyList()) + .portfolios(Collections.emptyList()) + .build()); + } + + private InvestmentTask createTaskWithModelPortfolios() { + return new InvestmentTask("model-portfolio-task", InvestmentData.builder() + .saName(SA_NAME) + .saExternalId(SA_EXTERNAL_ID) + .clientUsers(Collections.emptyList()) + .investmentArrangements(Collections.emptyList()) + .modelPortfolios(List.of(ModelPortfolio.builder() + .productTypeEnum(ProductTypeEnum.ROBO_ADVISOR) + .riskLevel(5) + .build())) + .investmentPortfolioTradingAccounts(Collections.emptyList()) + .portfolios(Collections.emptyList()) + .build()); + } + + private InvestmentTask createFullTask() { + return new InvestmentTask("full-task", InvestmentData.builder() + .saName(SA_NAME) + .saExternalId(SA_EXTERNAL_ID) + .clientUsers(List.of(ClientUser.builder() + .investmentClientId(UUID.randomUUID()) + .legalEntityExternalId(LE_EXTERNAL_ID) + .build())) + .investmentArrangements(List.of(InvestmentArrangement.builder() + .externalId(ARRANGEMENT_EXTERNAL_ID) + .build())) + .modelPortfolios(List.of(ModelPortfolio.builder() + .productTypeEnum(ProductTypeEnum.ROBO_ADVISOR) + .riskLevel(5) + .build())) + .investmentPortfolioTradingAccounts(List.of(InvestmentPortfolioTradingAccount.builder() + .portfolioExternalId(PORTFOLIO_EXTERNAL_ID) + .accountId(ACCOUNT_ID) + .accountExternalId(ACCOUNT_EXTERNAL_ID) + .isDefault(true) + .isInternal(false) + .build())) + .portfolios(List.of(new PortfolioList())) + .build()); + } + + private void stubAllServicesSuccess() { + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioProduct()))); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioList()))); + when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioService.upsertDeposits(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) + .thenReturn(Mono.empty()); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.empty()); + } + + private void wireTrivialPipelineAfterModelPortfolios() { + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.empty()); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of())); + when(investmentPortfolioService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of())); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of())); + when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) + .thenReturn(Mono.empty()); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.empty()); + } + +} diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentSagaTest.java deleted file mode 100644 index 1ac29da7a..000000000 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentSagaTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.backbase.stream.investment.service; - -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; - -@SpringBootTest -//@ActiveProfiles({"it", "moustache-bank", "moustache-bank-subsidiaries"}) -class InvestmentSagaTest { -/* -// @Autowired - InvestmentClientService clientService; - InvestmentSagaConfigurationProperties properties; - InvestmentSaga saga; - - @BeforeEach - void setUp() { -// clientService = Mockito.mock(InvestmentClientService.class); - properties = new InvestmentSagaConfigurationProperties(); - saga = new InvestmentSaga(clientService, properties); - } - - @Test - @Disabled("Spec currently does not include UUID field") - void createClient_success_noExistenceCheck() { - ClientCreateRequest request = new ClientCreateRequest(); - ClientCreate created = new ClientCreate(UUID.randomUUID()); -// when(clientService.createClient(any())).thenReturn(Mono.just(created)); - - InvestmentClientTask task = saga.newCreateTask(request); - saga.process(task); - -// StepVerifier.create() -// .assertNext(t -> { -// assertThat(t.getState()).isEqualTo(InvestmentClientTask.State.COMPLETED); -// assertThat(t.getCreatedClient()).isNotNull(); -// }) -// .verifyComplete(); - } - - @Test - @Disabled("Spec currently does not include UUID field") - void createClient_skipped_whenExists() throws Exception { - properties.setPreExistenceCheck(true); - ClientCreateRequest request = new ClientCreateRequest(); - UUID uuid = UUID.randomUUID(); - setUuidReflectively(request, uuid); - when(clientService.getClient(eq(uuid))).thenReturn(Mono.just(new OASClient())); - - InvestmentClientTask task = saga.newCreateTask(request); - - StepVerifier.create(saga.process(task)) - .assertNext(t -> { - assertThat(t.getState()).isEqualTo(InvestmentClientTask.State.COMPLETED); - assertThat(t.getCreatedClient()).isNull(); - assertThat(t.getHistory()).anyMatch(h -> "skipped".equals(h.getResult())); - }) - .verifyComplete(); - } - - @Test - @Disabled("Spec currently does not include UUID field") - void createClient_failure() { - ClientCreateRequest request = new ClientCreateRequest(); - when(clientService.createClient(any())).thenReturn(Mono.error(new RuntimeException("boom"))); - - InvestmentClientTask task = saga.newCreateTask(request); - - StepVerifier.create(saga.process(task)) - .assertNext(t -> assertThat(t.getState()).isEqualTo(InvestmentClientTask.State.FAILED)) - .verifyComplete(); - } - - @Test - @Disabled("Spec currently does not include UUID field") - void patchClient_success() { - UUID uuid = UUID.randomUUID(); - PatchedOASClientUpdateRequest patch = new PatchedOASClientUpdateRequest(); - when(clientService.patchClient(eq(uuid), any())).thenReturn(Mono.just(new OASClient())); - - InvestmentClientTask task = saga.newPatchTask(uuid, patch); - - StepVerifier.create(saga.process(task)) - .assertNext(t -> { - assertThat(t.getState()).isEqualTo(InvestmentClientTask.State.COMPLETED); - assertThat(t.getUpdatedClient()).isNotNull(); - }) - .verifyComplete(); - } - - @Test - @Disabled("Spec currently does not include UUID field") - void patchClient_notFoundFails() { - UUID uuid = UUID.randomUUID(); - PatchedOASClientUpdateRequest patch = new PatchedOASClientUpdateRequest(); - WebClientResponseException notFound = new WebClientResponseException(404, "Not Found", new HttpHeaders(), new byte[0], StandardCharsets.UTF_8); - when(clientService.patchClient(eq(uuid), any())).thenReturn(Mono.error(notFound)); - - InvestmentClientTask task = saga.newPatchTask(uuid, patch); - - StepVerifier.create(saga.process(task)) - .assertNext(t -> assertThat(t.getState()).isEqualTo(InvestmentClientTask.State.FAILED)) - .verifyComplete(); - } - - private void setUuidReflectively(Object target, UUID uuid) throws Exception { - try { - Field f = target.getClass().getDeclaredField("uuid"); - f.setAccessible(true); - f.set(target, uuid); - } catch (NoSuchFieldException ignored) { - // If spec changes and field not present, test should fail explicitly - throw ignored; - } - }*/ -} - From 9030ae42706f5df52a09eec3f7f3370d1b0b0996 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Thu, 12 Mar 2026 13:32:18 +0530 Subject: [PATCH 2/5] service tests --- .../service/InvestmentAssetPriceService.java | 6 +- .../InvestmentAssetUniverseService.java | 20 +- .../InvestmentModelPortfolioService.java | 6 +- .../service/InvestmentPortfolioService.java | 34 +- .../InvestmentRestDocumentContentService.java | 10 +- .../InvestmentAssetPriceServiceTest.java | 405 +++++ .../InvestmentAssetUniverseServiceTest.java | 1157 +++++++++++--- .../InvestmentCurrencyServiceTest.java | 389 +++++ ...vestmentIntradayAssetPriceServiceTest.java | 652 +++++++- .../InvestmentModelPortfolioServiceTest.java | 754 +++++++++ ...estmentPortfolioAllocationServiceTest.java | 656 ++++++++ .../InvestmentPortfolioServiceTest.java | 1411 +++++++++++++---- .../InvestmentRestNewsContentServiceTest.java | 45 - ...nvestmentRestAssetUniverseServiceTest.java | 355 +++++ ...estmentRestDocumentContentServiceTest.java | 262 +++ .../InvestmentRestNewsContentServiceTest.java | 312 ++++ 16 files changed, 5856 insertions(+), 618 deletions(-) create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetPriceServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentCurrencyServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java delete mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRestNewsContentServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetPriceService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetPriceService.java index 7301c0c55..e3f5aa665 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetPriceService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetPriceService.java @@ -58,8 +58,10 @@ private Mono>> generatePrices(List assets, Map { RandomPriceParam priceParam = findPrice(priceByAsset, asset, getLastPrice(prices)); LocalDate lastDate = diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java index ea8288b22..4acaedec8 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java @@ -71,10 +71,10 @@ public Mono upsertMarket(MarketRequest marketRequest) { }); }) // If Mono is empty (market not found), create the market - .switchIfEmpty(assetUniverseApi.createMarket(marketRequest) + .switchIfEmpty(Mono.defer(() -> assetUniverseApi.createMarket(marketRequest) .doOnSuccess(createdMarket -> log.info("Created market: {}", createdMarket)) .doOnError(error -> log.error("Error creating market: {}", error.getMessage(), error)) - ); + )); } /** @@ -108,7 +108,7 @@ public Mono getOrCreateAsset(com.backbase. }) .map(assetMapper::map) // If Mono is empty (asset not found), create the asset - .switchIfEmpty(investmentRestAssetUniverseService.createAsset(asset, categoryIdByCode) + .switchIfEmpty(Mono.defer(() -> investmentRestAssetUniverseService.createAsset(asset, categoryIdByCode) .doOnSuccess(createdAsset -> log.info("Created asset with assetIdentifier: {}", assetIdentifier)) .doOnError(error -> { if (error instanceof WebClientResponseException w) { @@ -119,7 +119,7 @@ public Mono getOrCreateAsset(com.backbase. error.getMessage(), error); } }) - ); + )); } /** @@ -171,7 +171,7 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar } }) // If Mono is empty (market special day not found), create the market special day - .switchIfEmpty(assetUniverseApi.createMarketSpecialDay(marketSpecialDayRequest) + .switchIfEmpty(Mono.defer(() -> assetUniverseApi.createMarketSpecialDay(marketSpecialDayRequest) .doOnSuccess( createdMarketSpecialDay -> log.info("Created market special day: {}", createdMarketSpecialDay)) .doOnError(error -> { @@ -184,7 +184,7 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar } }) - ); + )); } public Flux createAssets(List assets) { @@ -236,8 +236,8 @@ public Mono upsertAssetCategory(AssetCategoryEntry assetCategoryE return Mono.empty(); }) .switchIfEmpty( - investmentRestAssetUniverseService - .createAssetCategory(assetCategoryEntry, assetCategoryEntry.getImageResource()) + Mono.defer(() -> investmentRestAssetUniverseService + .createAssetCategory(assetCategoryEntry, assetCategoryEntry.getImageResource())) ) .doOnSuccess(updatedCategory -> { assetCategoryEntry.setUuid(updatedCategory.getUuid()); @@ -304,7 +304,7 @@ public Mono upsertAssetCategoryType(AssetCategoryTypeRequest } }) .switchIfEmpty( - assetUniverseApi.createAssetCategoryType(assetCategoryTypeRequest) + Mono.defer(() -> assetUniverseApi.createAssetCategoryType(assetCategoryTypeRequest) .doOnSuccess(createdType -> log.info("Created asset category type: {}", createdType)) .doOnError(error -> { if (error instanceof WebClientResponseException w) { @@ -316,7 +316,7 @@ public Mono upsertAssetCategoryType(AssetCategoryTypeRequest assetCategoryTypeRequest.getCode(), error.getMessage(), error); } - }) + })) ); } } \ No newline at end of file diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java index 19d6aa9c2..162bc59f5 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java @@ -98,7 +98,7 @@ private Mono upsertModelPortfolio(OASModelPortfolioRe return listExistingModelPortfolios(modelName, riskLevel) .flatMap(pm -> patchModelPortfolio(pm.getUuid(), modelPortfolio)) - .switchIfEmpty(createNewModelPortfolio(modelPortfolio)) + .switchIfEmpty(Mono.defer(() -> createNewModelPortfolio(modelPortfolio))) .doOnSuccess(upserted -> log.info( "Successfully upserted model portfolio: uuid={}, name={}, riskLevel={}", upserted.getUuid(), upserted.getName(), upserted.getRiskLevel())) @@ -167,7 +167,7 @@ private Mono patchModelPortfolio(UUID uuid, log.info("Patch model portfolio: name={}, riskLevel={}", modelPortfolio.getName(), modelPortfolio.getRiskLevel()); - log.debug("Patch model portfolio: iiud={}, object={}", uuid, modelPortfolio); + log.debug("Patch model portfolio: uuid={}, object={}", uuid, modelPortfolio); return customIntegrationApiService.patchModelPortfolioRequestCreation(uuid.toString(), null, null, null, modelPortfolio, null) .doOnSuccess(created -> log.info( @@ -192,7 +192,7 @@ private void logModelPortfolioError(String request, String name, Integer riskLev log.error("Failed to {} model portfolio: name={}, riskLevel={}, status={}, body={}", request, name, riskLevel, ex.getStatusCode(), ex.getResponseBodyAsString(), ex); } else { - log.error("Failed to {}} model portfolio: name={}, riskLevel={}", request, + log.error("Failed to {} model portfolio: name={}, riskLevel={}", request, name, riskLevel, throwable); } } 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 c41af64d2..80511ac91 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 @@ -9,7 +9,6 @@ import com.backbase.investment.api.service.v1.model.DepositTypeEnum; import com.backbase.investment.api.service.v1.model.IntegrationPortfolioCreateRequest; import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; -import com.backbase.investment.api.service.v1.model.PaginatedDepositList; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList; import com.backbase.investment.api.service.v1.model.PatchedPortfolioProductCreateUpdateRequest; @@ -153,9 +152,13 @@ public Mono> upsertInvestmentProducts(InvestmentData inve .flatMapIterable(this::distinctProducts) .flatMap(p -> listExistingPortfolioProducts(p) .flatMap( - existingProduct -> updateExistingPortfolioProduct(existingProduct, p, - investmentData)) - .switchIfEmpty(createPortfolioProductWithModel(p, investmentData)) + existingProduct -> updateExistingPortfolioProduct(existingProduct, p, investmentData) + .onErrorResume(WebClientResponseException.class, ex -> { + log.info("Using existing product data due to patch failure: uuid={}", existingProduct.getUuid()); + return Mono.just(existingProduct); + }) + ) + .switchIfEmpty(Mono.defer(() -> createPortfolioProductWithModel(p, investmentData))) .doOnSuccess(product -> log.info( "Successfully upserted portfolio product: engine={}, productType={}, model={}", product.getAdviceEngine(), product.getProductType(), @@ -241,7 +244,7 @@ public Mono upsertInvestmentPortfolios(InvestmentArrangement inve return listExistingPortfolios(externalId) .flatMap(p -> patchPortfolio(p, investmentArrangement, clientsByLeExternalId)) - .switchIfEmpty(createNewPortfolio(investmentArrangement, clientsByLeExternalId)) + .switchIfEmpty(Mono.defer(() -> createNewPortfolio(investmentArrangement, clientsByLeExternalId))) .doOnSuccess(portfolio -> log.info( "Successfully upserted investment portfolio: externalId={}, name={}, portfolioUuid={}", externalId, arrangementName, @@ -523,18 +526,19 @@ public Mono upsertDeposits(PortfolioList portfolio) { return paymentsApi.listDeposits(null, null, null, null, null, null, portfolio.getUuid(), null, null, null) .filter(Objects::nonNull) - .map(PaginatedDepositList::getResults) - .filter(Objects::nonNull) - .filter(Predicate.not(CollectionUtils::isEmpty)) + // Use flatMap with Mono.justOrEmpty() to safely handle null results without NPE, + // ensuring switchIfEmpty fallback triggers for both null and empty deposit lists. + .flatMap(paginatedResult -> + Mono.justOrEmpty(paginatedResult.getResults()) + .filter(list -> !list.isEmpty())) .flatMap(deposits -> { - Double deposit = deposits.stream().map(Deposit::getAmount).reduce(0d, Double::sum); - double additionalDeposit = defaultAmount - deposit; - if (additionalDeposit > 0) { - return createDeposit(portfolio, additionalDeposit); - } - return Mono.just(deposits.getLast()); + double deposited = deposits.stream().mapToDouble(Deposit::getAmount).sum(); + double remaining = defaultAmount - deposited; + return remaining > 0 + ? createDeposit(portfolio, remaining) + : Mono.just(deposits.getLast()); }) - .switchIfEmpty(createDeposit(portfolio, defaultAmount)) + .switchIfEmpty(Mono.defer(() -> createDeposit(portfolio, defaultAmount))) .onErrorResume(ex -> Mono.just(new Deposit() .portfolio(portfolio.getUuid()) .amount(defaultAmount) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java index 7dd60c62c..a6406e1ac 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java @@ -164,7 +164,10 @@ private Mono patchDocument(UUID uuid, ContentDocumentEntry request.getDocumentResource() != null); OASDocumentRequestDataRequest createDocumentRequest = contentMapper.map(request); log.debug("Document entry request mapped: {}", createDocumentRequest); - return Mono.defer(() -> Mono.just(patchDocument(uuid, createDocumentRequest, request.getDocumentResource())) + // Mono.just would evaluate patchDocument eagerly, throwing before onErrorResume can intercept; + // fromCallable defers execution so any synchronous exception is captured as a reactive error, + // allowing onErrorResume to swallow per-item failures and continue batch processing. + return Mono.defer(() -> Mono.fromCallable(() -> patchDocument(uuid, createDocumentRequest, request.getDocumentResource())) .doOnSuccess( created -> log.info("Document entry created successfully: title='{}', uuid={}, documentAttached={}", request.getName(), created.getUuid(), request.getDocumentResource() != null)) @@ -181,7 +184,10 @@ private Mono insertDocument(ContentDocumentEntry request) OASDocumentRequestDataRequest createDocumentRequest = contentMapper.map(request); log.debug("Document entry request mapped: {}", createDocumentRequest); - return Mono.defer(() -> Mono.just(createContentDocument(createDocumentRequest, request.getDocumentResource())) + // Mono.just would evaluate createContentDocument eagerly, throwing before onErrorResume can intercept; + // fromCallable defers execution so any synchronous exception is captured as a reactive error, + // allowing onErrorResume to swallow per-item failures and continue batch processing. + return Mono.defer(() -> Mono.fromCallable(() -> createContentDocument(createDocumentRequest, request.getDocumentResource())) .doOnSuccess( created -> log.info("Document entry created successfully: title='{}', uuid={}, documentAttached={}", request.getName(), created.getUuid(), request.getDocumentResource() != null)) diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetPriceServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetPriceServiceTest.java new file mode 100644 index 000000000..dcf62442f --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetPriceServiceTest.java @@ -0,0 +1,405 @@ +package com.backbase.stream.investment.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.v1.AssetUniverseApi; +import com.backbase.investment.api.service.v1.model.GroupResult; +import com.backbase.investment.api.service.v1.model.OASPrice; +import com.backbase.investment.api.service.v1.model.PaginatedOASPriceList; +import com.backbase.stream.investment.Asset; +import com.backbase.stream.investment.AssetPrice; +import com.backbase.stream.investment.RandomParam; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests for {@link InvestmentAssetPriceService}. + * + *

Tests are grouped by method under {@link Nested} classes to improve readability + * and navigation. Each nested class covers a single public method, and each test + * method covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom of the class reduce boilerplate
  • + *
+ */ +@DisplayName("InvestmentAssetPriceService") +class InvestmentAssetPriceServiceTest { + + private AssetUniverseApi assetUniverseApi; + private InvestmentAssetPriceService service; + + @BeforeEach + void setUp() { + assetUniverseApi = Mockito.mock(AssetUniverseApi.class); + service = new InvestmentAssetPriceService(assetUniverseApi); + } + + // ========================================================================= + // ingestPrices + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetPriceService#ingestPrices(List, Map)}. + * + *

Covers: + *

    + *
  • Null assets list → treated as empty, no API call, returns empty list
  • + *
  • Empty assets list → returns empty list without calling API
  • + *
  • No existing prices for asset → DEFAULT_START_PRICE used, bulk create called
  • + *
  • Asset has defaultPrice set, no AssetPrice in map → defaultPrice used as start
  • + *
  • AssetPrice entry present with custom price and RandomParam → custom values used
  • + *
  • Existing prices returned → last price used as start, next day chosen as lastDate
  • + *
  • Asset is fully up-to-date (all workdays already have prices) → no bulk create called
  • + *
  • listAssetClosePrices returns null response → filtered out, completes empty
  • + *
  • listAssetClosePrices returns response with null results → filtered, completes empty
  • + *
  • bulkCreateAssetClosePrice fails with WebClientResponseException → error propagated
  • + *
  • bulkCreateAssetClosePrice fails with non-WebClient exception → error propagated
  • + *
  • Multiple assets → results from all assets merged into flat list
  • + *
  • bulkCreate returns empty list → filtered by CollectionUtils.isEmpty, not included
  • + *
+ */ + @Nested + @DisplayName("ingestPrices") + class IngestPricesTests { + + @Test + @DisplayName("null assets list — returns empty list without calling API") + void ingestPrices_nullAssets_returnsEmptyListWithoutCallingApi() { + // Act & Assert + StepVerifier.create(service.ingestPrices(null, Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetClosePrices( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("empty assets list — returns empty list without calling API") + void ingestPrices_emptyAssets_returnsEmptyListWithoutCallingApi() { + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetClosePrices( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("no existing prices for asset — DEFAULT_START_PRICE used, bulk create is called") + void ingestPrices_noExistingPrices_usesDefaultStartPriceAndCallsBulkCreate() { + // Arrange + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + GroupResult groupResult = mock(GroupResult.class); + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(results -> !results.isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("asset has defaultPrice set, no AssetPrice in map — defaultPrice used as start") + void ingestPrices_assetHasDefaultPrice_noAssetPriceInMap_defaultPriceUsedAsStart() { + // Arrange — asset carries a defaultPrice of 250.0; no matching AssetPrice in map + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", 250.0); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + GroupResult groupResult = mock(GroupResult.class); + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert — pipeline must complete successfully with prices generated + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(results -> !results.isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("AssetPrice entry present with custom price and RandomParam — custom values forwarded to price generation") + void ingestPrices_assetPriceEntryPresent_customPriceAndRandomParamUsed() { + // Arrange + Asset asset = buildAsset("US5949181045", "NYSE", "USD", null); + AssetPrice assetPrice = new AssetPrice( + asset.getIsin(), asset.getMarket(), asset.getCurrency(), 350.0, + new RandomParam(0.98, 1.02)); + Map priceByAsset = Map.of(asset.getKeyString(), assetPrice); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + GroupResult groupResult = mock(GroupResult.class); + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), priceByAsset)) + .expectNextMatches(results -> !results.isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("existing prices returned — last price used as start, next day after last price chosen as lastDate") + void ingestPrices_existingPricesPresent_lastPriceUsedAsStartAndLastDateAdvanced() { + // Arrange — return a price dated well in the past so new work-days are generated + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + LocalDate pastDate = LocalDate.now().minusDays(15); + OASPrice existingPrice = mock(OASPrice.class); + when(existingPrice.getAmount()).thenReturn(180.0); + when(existingPrice.getDatetime()) + .thenReturn(OffsetDateTime.of(pastDate.atTime(0, 0), ZoneOffset.UTC)); + + PaginatedOASPriceList priceList = mock(PaginatedOASPriceList.class); + when(priceList.getResults()).thenReturn(List.of(existingPrice)); + stubListAssetClosePrices(asset, Mono.just(priceList)); + + GroupResult groupResult = mock(GroupResult.class); + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(results -> !results.isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("asset is fully up-to-date — no work-days to create, bulk create never called, result is empty") + void ingestPrices_assetFullyUpToDate_noBulkCreateCalled_resultIsEmpty() { + // Arrange — return a price dated today, so no further work-days remain + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + // Use today's date; lastDate = today+1 which is >= now → workDays returns empty + LocalDate today = LocalDate.now(); + OASPrice todayPrice = mock(OASPrice.class); + when(todayPrice.getAmount()).thenReturn(150.0); + when(todayPrice.getDatetime()) + .thenReturn(OffsetDateTime.of(today.atTime(0, 0), ZoneOffset.UTC)); + + PaginatedOASPriceList priceList = mock(PaginatedOASPriceList.class); + when(priceList.getResults()).thenReturn(List.of(todayPrice)); + stubListAssetClosePrices(asset, Mono.just(priceList)); + + // Act & Assert — empty because no new days need to be created + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateAssetClosePrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("listAssetClosePrices returns null (Mono.empty) — filtered out, completes with empty list") + void ingestPrices_listPricesReturnsNull_filteredOut_completesWithEmptyList() { + // Arrange — simulate API returning Mono.empty (no element emitted) + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + stubListAssetClosePrices(asset, Mono.empty()); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateAssetClosePrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("listAssetClosePrices response has null results — filtered, completes with empty list") + void ingestPrices_listPricesResponseHasNullResults_filteredOut_completesWithEmptyList() { + // Arrange — PaginatedOASPriceList.getResults() returns null + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + PaginatedOASPriceList nullResultsList = mock(PaginatedOASPriceList.class); + when(nullResultsList.getResults()).thenReturn(null); + stubListAssetClosePrices(asset, Mono.just(nullResultsList)); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateAssetClosePrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("bulkCreateAssetClosePrice fails with WebClientResponseException — error propagated to subscriber") + void ingestPrices_bulkCreateFailsWithWebClientException_errorPropagated() { + // Arrange + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + WebClientResponseException apiError = WebClientResponseException.create( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", + HttpHeaders.EMPTY, "bulk create failed".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.error(apiError)); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectErrorMatches(e -> e instanceof WebClientResponseException + && ((WebClientResponseException) e).getStatusCode() == HttpStatus.INTERNAL_SERVER_ERROR) + .verify(); + } + + @Test + @DisplayName("bulkCreateAssetClosePrice fails with non-WebClient exception — error propagated to subscriber") + void ingestPrices_bulkCreateFailsWithNonWebClientException_errorPropagated() { + // Arrange + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.error(new RuntimeException("Unexpected persistence error"))); + + // Act & Assert + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectErrorMatches(e -> e instanceof RuntimeException + && "Unexpected persistence error".equals(e.getMessage())) + .verify(); + } + + @Test + @DisplayName("multiple assets — results from all assets merged into a single flat list") + void ingestPrices_multipleAssets_resultsMergedIntoFlatList() { + // Arrange + Asset asset1 = buildAsset("US0378331005", "NASDAQ", "USD", null); + Asset asset2 = buildAsset("US5949181045", "NYSE", "USD", null); + + PaginatedOASPriceList emptyList = mock(PaginatedOASPriceList.class); + when(emptyList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset1, Mono.just(emptyList)); + stubListAssetClosePrices(asset2, Mono.just(emptyList)); + + GroupResult result1 = mock(GroupResult.class); + GroupResult result2 = mock(GroupResult.class); + // Both assets produce one GroupResult each + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(result1)) + .thenReturn(Flux.just(result2)); + + // Act & Assert — flat list contains entries from both assets + StepVerifier.create(service.ingestPrices(List.of(asset1, asset2), Map.of())) + .expectNextMatches(results -> results.size() >= 1) + .verifyComplete(); + } + + @Test + @DisplayName("bulkCreate returns empty list — filtered by CollectionUtils.isEmpty, not included in result") + void ingestPrices_bulkCreateReturnsEmptyList_filteredOut_notIncludedInResult() { + // Arrange — bulkCreate Flux emits nothing (empty), so collectList yields empty List + Asset asset = buildAsset("US0378331005", "NASDAQ", "USD", null); + + PaginatedOASPriceList emptyPriceList = mock(PaginatedOASPriceList.class); + when(emptyPriceList.getResults()).thenReturn(List.of()); + stubListAssetClosePrices(asset, Mono.just(emptyPriceList)); + + // Flux.empty() → collectList() → Mono<[]> → filter(not(isEmpty)) blocks it + when(assetUniverseApi.bulkCreateAssetClosePrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.empty()); + + // Act & Assert — the empty sub-result is filtered; final flat list is empty + StepVerifier.create(service.ingestPrices(List.of(asset), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Builds an {@link Asset} with the given identifiers and an optional defaultPrice. + * + * @param isin ISIN code + * @param market market code + * @param currency ISO currency code + * @param defaultPrice optional default price; may be {@code null} + * @return a fully populated {@link Asset} + */ + private Asset buildAsset(String isin, String market, String currency, Double defaultPrice) { + Asset asset = new Asset(); + asset.setUuid(UUID.randomUUID()); + asset.setIsin(isin); + asset.setMarket(market); + asset.setCurrency(currency); + asset.setDefaultPrice(defaultPrice); + return asset; + } + + /** + * Stubs {@link AssetUniverseApi#listAssetClosePrices} for the given asset to return + * the provided {@link Mono}. + * + * @param asset the asset whose close prices should be stubbed + * @param response the {@link Mono} to return + */ + private void stubListAssetClosePrices(Asset asset, Mono response) { + when(assetUniverseApi.listAssetClosePrices( + eq(asset.getCurrency()), + any(), any(), + isNull(), isNull(), isNull(), isNull(), + eq(asset.getIsin()), + anyInt(), + eq(asset.getMarket()), + isNull(), isNull())) + .thenReturn(response); + } +} + diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java index 5733244d3..cd4422e03 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java @@ -1,233 +1,974 @@ 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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.sync.v1.model.AssetCategory; import com.backbase.investment.api.service.v1.AssetUniverseApi; -import com.backbase.investment.api.service.v1.model.Asset; +import com.backbase.investment.api.service.v1.model.AssetCategoryType; +import com.backbase.investment.api.service.v1.model.AssetCategoryTypeRequest; import com.backbase.investment.api.service.v1.model.Market; import com.backbase.investment.api.service.v1.model.MarketRequest; +import com.backbase.investment.api.service.v1.model.MarketSpecialDay; +import com.backbase.investment.api.service.v1.model.MarketSpecialDayRequest; +import com.backbase.investment.api.service.v1.model.PaginatedAssetCategoryList; +import com.backbase.investment.api.service.v1.model.PaginatedAssetCategoryTypeList; +import com.backbase.investment.api.service.v1.model.PaginatedMarketSpecialDayList; +import com.backbase.stream.investment.model.AssetCategoryEntry; import com.backbase.stream.investment.service.resttemplate.InvestmentRestAssetUniverseService; import java.nio.charset.StandardCharsets; -import java.util.HashMap; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; - /** - * This is a custom implementation to avoid issues with Reactive and multipart requests. + * Unit tests for {@link InvestmentAssetUniverseService}. + * + *

Tests are grouped by method under {@link Nested} classes. Each nested class covers a single + * public method, and each test covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom reduce boilerplate
  • + *
*/ +@DisplayName("InvestmentAssetUniverseService") class InvestmentAssetUniverseServiceTest { - InvestmentAssetUniverseService service; - AssetUniverseApi assetUniverseApi; - InvestmentRestAssetUniverseService investmentRestAssetUniverseService; + private AssetUniverseApi assetUniverseApi; + private InvestmentRestAssetUniverseService investmentRestAssetUniverseService; + private InvestmentAssetUniverseService service; @BeforeEach void setUp() { - assetUniverseApi = Mockito.mock(AssetUniverseApi.class); - investmentRestAssetUniverseService = Mockito.mock(InvestmentRestAssetUniverseService.class); - service = new InvestmentAssetUniverseService(assetUniverseApi, - investmentRestAssetUniverseService); - } - - @Test - void upsertMarket_marketExists() { - MarketRequest request = new MarketRequest().code("US"); - Market market = new Market().code("US").name("Usa Market"); - Market marketUpdated = new Market().code("US").name("Usa Market Updated"); - Mockito.when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(market)); - Mockito.when(assetUniverseApi.createMarket(request)).thenReturn(Mono.just(market)); - Mockito.when(assetUniverseApi.updateMarket("US", request)).thenReturn(Mono.just(marketUpdated)); - - StepVerifier.create(service.upsertMarket(request)) - .expectNext(marketUpdated) - .verifyComplete(); - } - - @Test - void upsertMarket_marketNotFound_createsMarket() { - MarketRequest request = new MarketRequest().code("US"); - Market createdMarket = new Market().code("US"); - Mockito.when(assetUniverseApi.getMarket("US")) - .thenReturn(Mono.error(WebClientResponseException.create( - HttpStatus.NOT_FOUND.value(), - "Not Found", - HttpHeaders.EMPTY, - null, - StandardCharsets.UTF_8 - ))); - Mockito.when(assetUniverseApi.createMarket(request)).thenReturn(Mono.just(createdMarket)); - - StepVerifier.create(service.upsertMarket(request)) - .expectNext(createdMarket) - .verifyComplete(); - } - - @Test - void upsertMarket_otherError_propagates() { - MarketRequest request = new MarketRequest().code("US"); - Mockito.when(assetUniverseApi.getMarket("US")) - .thenReturn(Mono.error(new RuntimeException("API error"))); - Mockito.when(assetUniverseApi.createMarket(request)).thenReturn(Mono.empty()); - - StepVerifier.create(service.upsertMarket(request)) - .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().equals("API error")) - .verify(); - } - - @Test - void getOrCreateAsset_assetExists() { - com.backbase.stream.investment.Asset req = createAsset(); - com.backbase.stream.investment.Asset asset = createAsset(); - Asset existingAsset = new Asset() - .isin("ABC123") - .market("market") - .currency("USD"); - -// req.setIsin("ABC123"); -// req.setMarket("market"); -// req.setCurrency("USD"); - - Mockito.when(assetUniverseApi.getAsset( - ArgumentMatchers.anyString(), - ArgumentMatchers.any(), - ArgumentMatchers.any(), - ArgumentMatchers.any())) - .thenReturn(Mono.just(existingAsset)); - Mockito.when(investmentRestAssetUniverseService.patchAsset(ArgumentMatchers.any(), ArgumentMatchers.any(), - HashMap.newHashMap(1))) - .thenReturn(Mono.just(asset)); - Mockito.when(investmentRestAssetUniverseService.createAsset( - ArgumentMatchers.any(), - ArgumentMatchers.any())) - .thenReturn(Mono.just(asset)); // This won't be called, but needed for switchIfEmpty evaluation - - StepVerifier.create(service.getOrCreateAsset(req, null)) - .expectNextMatches( - asset1 -> asset1.getIsin().equals(req.getIsin()) && asset1.getMarket().equals(req.getMarket()) - && asset1.getCurrency() - .equals(req.getCurrency())) - .verifyComplete(); - } - - private static com.backbase.stream.investment.Asset createAsset() { - com.backbase.stream.investment.Asset req = new com.backbase.stream.investment.Asset(); - req.setIsin("ABC123"); - req.setMarket("market"); - req.setCurrency("USD"); - return req; - } - - @Test - void getOrCreateAsset_assetNotFound_createsAsset() { - com.backbase.stream.investment.Asset req = createAsset(); - com.backbase.stream.investment.Asset createdAsset = createAsset(); - String assetId = "ABC123_market_USD"; - - Mockito.when(assetUniverseApi.getAsset( - ArgumentMatchers.eq(assetId), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull())) - .thenReturn(Mono.error(WebClientResponseException.create( - HttpStatus.NOT_FOUND.value(), - "Not Found", - HttpHeaders.EMPTY, - null, - StandardCharsets.UTF_8 - ))); - Mockito.when(investmentRestAssetUniverseService.createAsset( - ArgumentMatchers.eq(req), - ArgumentMatchers.eq(Map.of()))) - .thenReturn(Mono.just(createdAsset)); - - StepVerifier.create(service.getOrCreateAsset(req, Map.of())) - .expectNext(createdAsset) - .verifyComplete(); - } - - @Test - void getOrCreateAsset_otherError_propagates() { - com.backbase.stream.investment.Asset req = createAsset(); - String assetId = "ABC123_market_USD"; - Mockito.when(assetUniverseApi.getAsset( - ArgumentMatchers.eq(assetId), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull())) - .thenReturn(Mono.error(new RuntimeException("API error"))); - Mockito.when(investmentRestAssetUniverseService.createAsset( - ArgumentMatchers.eq(req), - ArgumentMatchers.isNull())) - .thenReturn(Mono.empty()); // This won't be called, but needed for switchIfEmpty evaluation - - StepVerifier.create(service.getOrCreateAsset(req, null)) - .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().equals("API error")) - .verify(); - } - - @Test - void getOrCreateAsset_createAssetFails_propagates() { - com.backbase.stream.investment.Asset req = createAsset(); - String assetId = "ABC123_market_USD"; - Mockito.when(assetUniverseApi.getAsset( - ArgumentMatchers.eq(assetId), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull())) - .thenReturn(Mono.error(WebClientResponseException.create( - HttpStatus.NOT_FOUND.value(), - "Not Found", - HttpHeaders.EMPTY, - null, - StandardCharsets.UTF_8 - ))); - Mockito.when(investmentRestAssetUniverseService.createAsset( - ArgumentMatchers.eq(req), - ArgumentMatchers.isNull())) - .thenReturn(Mono.error(new RuntimeException("Create asset failed"))); - - StepVerifier.create(service.getOrCreateAsset(req, null)) - .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().equals("Create asset failed")) - .verify(); - } - - @Test - void getOrCreateAsset_nullRequest_returnsError() { - StepVerifier.create(Mono.defer(() -> service.getOrCreateAsset(null, null))) - .expectError(NullPointerException.class) - .verify(); - } - - @Test - void getOrCreateAsset_emptyMonoFromCreateAsset() { - com.backbase.stream.investment.Asset req = createAsset(); - String assetId = "ABC123_market_USD"; - Mockito.when(assetUniverseApi.getAsset( - ArgumentMatchers.eq(assetId), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull(), - ArgumentMatchers.isNull())) - .thenReturn(Mono.error(WebClientResponseException.create( - HttpStatus.NOT_FOUND.value(), - "Not Found", - HttpHeaders.EMPTY, - null, - StandardCharsets.UTF_8 - ))); - Mockito.when(investmentRestAssetUniverseService.createAsset( - ArgumentMatchers.eq(req), - ArgumentMatchers.isNull())) - .thenReturn(Mono.empty()); - - StepVerifier.create(service.getOrCreateAsset(req, null)) - .expectComplete() - .verify(); + assetUniverseApi = mock(AssetUniverseApi.class); + investmentRestAssetUniverseService = mock(InvestmentRestAssetUniverseService.class); + service = new InvestmentAssetUniverseService(assetUniverseApi, investmentRestAssetUniverseService); + } + + // ========================================================================= + // upsertMarket + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#upsertMarket(MarketRequest)}. + * + *

Covers: + *

    + *
  • Market already exists → updateMarket is called and updated market returned
  • + *
  • Market not found (404) → createMarket is called and created market returned
  • + *
  • Non-404 error on getMarket → error propagated as-is
  • + *
  • Market exists but updateMarket fails → error propagated
  • + *
  • Market not found and createMarket fails → error propagated
  • + *
+ */ + @Nested + @DisplayName("upsertMarket") + class UpsertMarketTests { + + @Test + @DisplayName("market already exists — updateMarket is called and updated market returned") + void upsertMarket_marketExists_updateCalledAndReturned() { + // Arrange + MarketRequest request = new MarketRequest().code("US"); + Market existing = new Market().code("US").name("US Market"); + Market updated = new Market().code("US").name("US Market Updated"); + + when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); + when(assetUniverseApi.updateMarket(eq("US"), eq(request))).thenReturn(Mono.just(updated)); + when(assetUniverseApi.createMarket(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.upsertMarket(request)) + .expectNext(updated) + .verifyComplete(); + + verify(assetUniverseApi).updateMarket(eq("US"), eq(request)); + verify(assetUniverseApi, never()).createMarket(any()); + } + + @Test + @DisplayName("market not found (404) — createMarket is called and created market returned") + void upsertMarket_marketNotFound_createCalledAndReturned() { + // Arrange + MarketRequest request = new MarketRequest().code("US"); + Market created = new Market().code("US").name("US Market"); + + when(assetUniverseApi.getMarket("US")).thenReturn(Mono.error(notFound())); + when(assetUniverseApi.createMarket(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertMarket(request)) + .expectNext(created) + .verifyComplete(); + + verify(assetUniverseApi).createMarket(request); + verify(assetUniverseApi, never()).updateMarket(any(), any()); + } + + @Test + @DisplayName("non-404 error from getMarket — error propagated without calling create or update") + void upsertMarket_nonNotFoundError_propagated() { + // Arrange + MarketRequest request = new MarketRequest().code("US"); + + when(assetUniverseApi.getMarket("US")) + .thenReturn(Mono.error(new RuntimeException("API error"))); + + // Act & Assert + StepVerifier.create(service.upsertMarket(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "API error".equals(e.getMessage())) + .verify(); + + verify(assetUniverseApi, never()).createMarket(any()); + verify(assetUniverseApi, never()).updateMarket(any(), any()); + } + + @Test + @DisplayName("market exists but updateMarket fails — error propagated") + void upsertMarket_updateFails_errorPropagated() { + // Arrange + MarketRequest request = new MarketRequest().code("US"); + Market existing = new Market().code("US").name("US Market"); + + when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); + when(assetUniverseApi.updateMarket(eq("US"), eq(request))) + .thenReturn(Mono.error(new RuntimeException("update failed"))); + when(assetUniverseApi.createMarket(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.upsertMarket(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "update failed".equals(e.getMessage())) + .verify(); + } + + @Test + @DisplayName("market not found and createMarket fails — error propagated") + void upsertMarket_notFoundAndCreateFails_errorPropagated() { + // Arrange + MarketRequest request = new MarketRequest().code("US"); + + when(assetUniverseApi.getMarket("US")).thenReturn(Mono.error(notFound())); + when(assetUniverseApi.createMarket(request)) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.upsertMarket(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "create failed".equals(e.getMessage())) + .verify(); + } + } + + // ========================================================================= + // getOrCreateAsset + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#getOrCreateAsset}. + * + *

Covers: + *

    + *
  • Asset already exists → patchAsset is called, asset mapped and returned
  • + *
  • Asset not found (404) → createAsset is called and created asset returned
  • + *
  • Non-404 error from getAsset → error propagated
  • + *
  • Asset not found and createAsset fails → error propagated
  • + *
  • Asset not found and createAsset returns empty → completes empty
  • + *
  • Null request → NullPointerException
  • + *
+ */ + @Nested + @DisplayName("getOrCreateAsset") + class GetOrCreateAssetTests { + + @Test + @DisplayName("asset already exists — patchAsset is called and mapped asset returned") + void getOrCreateAsset_assetExists_patchCalledAndMappedReturned() { + // Arrange + com.backbase.stream.investment.Asset req = buildAsset(); + com.backbase.investment.api.service.v1.model.Asset existingApiAsset = + new com.backbase.investment.api.service.v1.model.Asset() + .isin("ABC123") + .market("market") + .currency("USD"); + com.backbase.stream.investment.Asset patchedAsset = buildAsset(); + + when(assetUniverseApi.getAsset(eq("ABC123_market_USD"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(existingApiAsset)); + when(investmentRestAssetUniverseService.patchAsset(eq(existingApiAsset), eq(req), any())) + .thenReturn(Mono.just(patchedAsset)); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.getOrCreateAsset(req, null)) + .expectNextMatches(a -> "ABC123".equals(a.getIsin()) + && "market".equals(a.getMarket()) + && "USD".equals(a.getCurrency())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).patchAsset(eq(existingApiAsset), eq(req), any()); + verify(investmentRestAssetUniverseService, never()).createAsset(any(), any()); + } + + @Test + @DisplayName("asset not found (404) — createAsset is called and created asset returned") + void getOrCreateAsset_assetNotFound_createCalledAndReturned() { + // Arrange + com.backbase.stream.investment.Asset req = buildAsset(); + com.backbase.stream.investment.Asset created = buildAsset(); + + when(assetUniverseApi.getAsset(eq("ABC123_market_USD"), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(notFound())); + when(investmentRestAssetUniverseService.createAsset(eq(req), eq(Map.of()))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.getOrCreateAsset(req, Map.of())) + .expectNext(created) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).createAsset(eq(req), eq(Map.of())); + verify(investmentRestAssetUniverseService, never()) + .patchAsset( + any(com.backbase.investment.api.service.v1.model.Asset.class), + any(com.backbase.stream.investment.Asset.class), + any()); + } + + @Test + @DisplayName("non-404 error from getAsset — error propagated") + void getOrCreateAsset_nonNotFoundError_propagated() { + // Arrange + com.backbase.stream.investment.Asset req = buildAsset(); + + when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("API error"))); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.getOrCreateAsset(req, null)) + .expectErrorMatches(e -> e instanceof RuntimeException && "API error".equals(e.getMessage())) + .verify(); + + verify(investmentRestAssetUniverseService, never()).createAsset(any(), any()); + verify(investmentRestAssetUniverseService, never()) + .patchAsset( + any(com.backbase.investment.api.service.v1.model.Asset.class), + any(com.backbase.stream.investment.Asset.class), + any()); + } + + @Test + @DisplayName("asset not found and createAsset fails — error propagated") + void getOrCreateAsset_notFoundAndCreateFails_errorPropagated() { + // Arrange + com.backbase.stream.investment.Asset req = buildAsset(); + + when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(notFound())); + when(investmentRestAssetUniverseService.createAsset(eq(req), isNull())) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.getOrCreateAsset(req, null)) + .expectErrorMatches(e -> e instanceof RuntimeException && "create failed".equals(e.getMessage())) + .verify(); + } + + @Test + @DisplayName("asset not found and createAsset returns empty — completes empty") + void getOrCreateAsset_notFoundAndCreateReturnsEmpty_completesEmpty() { + // Arrange + com.backbase.stream.investment.Asset req = buildAsset(); + + when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(notFound())); + when(investmentRestAssetUniverseService.createAsset(eq(req), isNull())) + .thenReturn(Mono.empty()); + + // Act & Assert + StepVerifier.create(service.getOrCreateAsset(req, null)) + .verifyComplete(); + } + + @Test + @DisplayName("null asset request — NullPointerException thrown") + void getOrCreateAsset_nullRequest_throwsNullPointerException() { + StepVerifier.create(Mono.defer(() -> service.getOrCreateAsset(null, null))) + .expectError(NullPointerException.class) + .verify(); + } + } + + // ========================================================================= + // upsertMarketSpecialDay + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#upsertMarketSpecialDay(MarketSpecialDayRequest)}. + * + *

Covers: + *

    + *
  • Matching special day exists → updateMarketSpecialDay is called and updated day returned
  • + *
  • Special day list is empty → createMarketSpecialDay is called and created day returned
  • + *
  • Special days exist but none match the requested market → createMarketSpecialDay is called
  • + *
  • Update of existing special day fails → error propagated
  • + *
  • createMarketSpecialDay fails → error propagated
  • + *
+ */ + @Nested + @DisplayName("upsertMarketSpecialDay") + class UpsertMarketSpecialDayTests { + + @Test + @DisplayName("matching special day exists — updateMarketSpecialDay called and updated day returned") + void upsertMarketSpecialDay_matchingExists_updateCalledAndReturned() { + // Arrange + LocalDate date = LocalDate.of(2025, 12, 25); + UUID existingUuid = UUID.randomUUID(); + MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); + MarketSpecialDay existing = buildMarketSpecialDay(existingUuid, "NYSE", date); + MarketSpecialDay updated = buildMarketSpecialDay(existingUuid, "NYSE", date); + + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); + when(assetUniverseApi.updateMarketSpecialDay(eq(existingUuid.toString()), eq(request))) + .thenReturn(Mono.just(updated)); + when(assetUniverseApi.createMarketSpecialDay(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectNext(updated) + .verifyComplete(); + + verify(assetUniverseApi).updateMarketSpecialDay(eq(existingUuid.toString()), eq(request)); + verify(assetUniverseApi, never()).createMarketSpecialDay(any()); + } + + @Test + @DisplayName("special day list is empty — createMarketSpecialDay called and created day returned") + void upsertMarketSpecialDay_emptyList_createCalledAndReturned() { + // Arrange + LocalDate date = LocalDate.of(2025, 12, 25); + MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); + MarketSpecialDay created = buildMarketSpecialDay(UUID.randomUUID(), "NYSE", date); + + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of()))); + when(assetUniverseApi.createMarketSpecialDay(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectNext(created) + .verifyComplete(); + + verify(assetUniverseApi).createMarketSpecialDay(request); + verify(assetUniverseApi, never()).updateMarketSpecialDay(any(), any()); + } + + @Test + @DisplayName("special days exist but none match requested market — createMarketSpecialDay called") + void upsertMarketSpecialDay_noMatchingMarket_createCalledAndReturned() { + // Arrange + LocalDate date = LocalDate.of(2025, 12, 25); + MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NASDAQ", date); + MarketSpecialDay created = buildMarketSpecialDay(UUID.randomUUID(), "NASDAQ", date); + + // Only NYSE entry exists; NASDAQ is the request + MarketSpecialDay nyseEntry = buildMarketSpecialDay(UUID.randomUUID(), "NYSE", date); + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(nyseEntry)))); + when(assetUniverseApi.createMarketSpecialDay(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectNext(created) + .verifyComplete(); + + verify(assetUniverseApi).createMarketSpecialDay(request); + verify(assetUniverseApi, never()).updateMarketSpecialDay(any(), any()); + } + + @Test + @DisplayName("matching special day exists but update fails — error propagated") + void upsertMarketSpecialDay_updateFails_errorPropagated() { + // Arrange + LocalDate date = LocalDate.of(2025, 12, 25); + UUID existingUuid = UUID.randomUUID(); + MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); + MarketSpecialDay existing = buildMarketSpecialDay(existingUuid, "NYSE", date); + + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); + when(assetUniverseApi.updateMarketSpecialDay(eq(existingUuid.toString()), eq(request))) + .thenReturn(Mono.error(new RuntimeException("update failed"))); + when(assetUniverseApi.createMarketSpecialDay(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "update failed".equals(e.getMessage())) + .verify(); + } + + @Test + @DisplayName("createMarketSpecialDay fails — error propagated") + void upsertMarketSpecialDay_createFails_errorPropagated() { + // Arrange + LocalDate date = LocalDate.of(2025, 12, 25); + MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); + + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of()))); + when(assetUniverseApi.createMarketSpecialDay(request)) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "create failed".equals(e.getMessage())) + .verify(); + } + } + + // ========================================================================= + // upsertAssetCategory + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#upsertAssetCategory(AssetCategoryEntry)}. + * + *

Covers: + *

    + *
  • Null entry → returns Mono.empty()
  • + *
  • Matching category exists → patchAssetCategory is called and result returned
  • + *
  • No category in list matches code → createAssetCategory is called and result returned
  • + *
  • Results list is empty → createAssetCategory is called
  • + *
  • Patch fails → onErrorResume swallows error, Mono.empty() returned
  • + *
  • Create fails → onErrorResume swallows error, Mono.empty() returned
  • + *
  • Successful patch → entry uuid updated to the patched category uuid
  • + *
+ */ + @Nested + @DisplayName("upsertAssetCategory") + class UpsertAssetCategoryTests { + + @Test + @DisplayName("null entry — returns empty without calling any API") + void upsertAssetCategory_nullEntry_returnsEmpty() { + // Act & Assert + StepVerifier.create(service.upsertAssetCategory(null)) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetCategories(any(), any(), any(), any(), any(), any()); + verify(investmentRestAssetUniverseService, never()).patchAssetCategory(any(), any(), any()); + verify(investmentRestAssetUniverseService, never()).createAssetCategory(any(), any()); + } + + @Test + @DisplayName("matching category exists — patchAssetCategory called and result returned") + void upsertAssetCategory_matchingCategoryExists_patchCalledAndReturned() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + buildApiAssetCategory(existingUuid, "EQUITY"); + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); + when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + .thenReturn(Mono.just(patchedCategory)); + when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + + // Act & Assert + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).patchAssetCategory(eq(existingUuid), eq(entry), isNull()); + verify(investmentRestAssetUniverseService, never()).createAssetCategory(any(), any()); + } + + @Test + @DisplayName("no matching category in list — createAssetCategory called and result returned") + void upsertAssetCategory_noMatchingCategory_createCalledAndReturned() { + // Arrange + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + // Different code in the list — "BONDS" won't match "EQUITY" + com.backbase.investment.api.service.v1.model.AssetCategory other = + buildApiAssetCategory(UUID.randomUUID(), "BONDS"); + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(other)))); + + UUID newUuid = UUID.randomUUID(); + AssetCategory created = buildSyncAssetCategory(newUuid); + when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> newUuid.equals(result.getUuid())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).createAssetCategory(eq(entry), isNull()); + verify(investmentRestAssetUniverseService, never()).patchAssetCategory(any(), any(), any()); + } + + @Test + @DisplayName("results list is empty — createAssetCategory called") + void upsertAssetCategory_emptyResultsList_createCalledAndReturned() { + // Arrange + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of()))); + + UUID newUuid = UUID.randomUUID(); + AssetCategory created = buildSyncAssetCategory(newUuid); + when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> newUuid.equals(result.getUuid())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).createAssetCategory(eq(entry), isNull()); + } + + @Test + @DisplayName("patch fails — onErrorResume swallows error, Mono.empty() returned") + void upsertAssetCategory_patchFails_errorSwallowedReturnsEmpty() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + buildApiAssetCategory(existingUuid, "EQUITY"); + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + .thenReturn(Mono.error(new RuntimeException("patch failed"))); + when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback (not actually called on error) + + // Act & Assert — onErrorResume returns Mono.empty() + StepVerifier.create(service.upsertAssetCategory(entry)) + .verifyComplete(); + } + + @Test + @DisplayName("create fails — onErrorResume swallows error, Mono.empty() returned") + void upsertAssetCategory_createFails_errorSwallowedReturnsEmpty() { + // Arrange + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of()))); + when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert — onErrorResume returns Mono.empty() + StepVerifier.create(service.upsertAssetCategory(entry)) + .verifyComplete(); + } + + @Test + @DisplayName("successful patch — entry uuid is updated to patched category uuid") + void upsertAssetCategory_successfulPatch_entryUuidUpdated() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = buildAssetCategoryEntry("EQUITY", "Equities", null); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + buildApiAssetCategory(existingUuid, "EQUITY"); + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); + when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + .thenReturn(Mono.just(patchedCategory)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + // entry.uuid should be set to the patched category uuid via doOnSuccess + assertThat(entry.getUuid()).isEqualTo(existingUuid); + } + } + + // ========================================================================= + // upsertAssetCategoryType + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#upsertAssetCategoryType(AssetCategoryTypeRequest)}. + * + *

Covers: + *

    + *
  • Null request → returns Mono.empty()
  • + *
  • Matching type exists → updateAssetCategoryType is called and updated type returned
  • + *
  • Results list is null → createAssetCategoryType is called
  • + *
  • Results list is empty → createAssetCategoryType is called
  • + *
  • Results exist but none match code → createAssetCategoryType is called
  • + *
  • Update fails → onErrorResume swallows, Mono.empty() returned
  • + *
  • Create fails → error propagated
  • + *
+ */ + @Nested + @DisplayName("upsertAssetCategoryType") + class UpsertAssetCategoryTypeTests { + + @Test + @DisplayName("null request — returns empty without calling any API") + void upsertAssetCategoryType_nullRequest_returnsEmpty() { + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(null)) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetCategoryTypes(any(), any(), any(), any()); + verify(assetUniverseApi, never()).updateAssetCategoryType(any(), any()); + verify(assetUniverseApi, never()).createAssetCategoryType(any()); + } + + @Test + @DisplayName("matching type exists — updateAssetCategoryType called and updated type returned") + void upsertAssetCategoryType_matchingExists_updateCalledAndReturned() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); + + AssetCategoryType updated = buildAssetCategoryType(existingUuid, "SECTOR", "Sector Updated"); + when(assetUniverseApi.updateAssetCategoryType(eq(existingUuid.toString()), eq(request))) + .thenReturn(Mono.just(updated)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectNextMatches(result -> "SECTOR".equals(result.getCode())) + .verifyComplete(); + + verify(assetUniverseApi).updateAssetCategoryType(eq(existingUuid.toString()), eq(request)); + verify(assetUniverseApi, never()).createAssetCategoryType(any()); + } + + @Test + @DisplayName("results list is null — createAssetCategoryType called and created type returned") + void upsertAssetCategoryType_nullResultsList_createCalledAndReturned() { + // Arrange + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + PaginatedAssetCategoryTypeList page = new PaginatedAssetCategoryTypeList(); + page.setResults(null); + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(page)); + + AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); + when(assetUniverseApi.createAssetCategoryType(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectNextMatches(result -> "SECTOR".equals(result.getCode())) + .verifyComplete(); + + verify(assetUniverseApi).createAssetCategoryType(request); + verify(assetUniverseApi, never()).updateAssetCategoryType(any(), any()); + } + + @Test + @DisplayName("results list is empty — createAssetCategoryType called and created type returned") + void upsertAssetCategoryType_emptyResultsList_createCalledAndReturned() { + // Arrange + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of()))); + + AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); + when(assetUniverseApi.createAssetCategoryType(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectNextMatches(result -> "SECTOR".equals(result.getCode())) + .verifyComplete(); + + verify(assetUniverseApi).createAssetCategoryType(request); + verify(assetUniverseApi, never()).updateAssetCategoryType(any(), any()); + } + + @Test + @DisplayName("results exist but none match code — createAssetCategoryType called") + void upsertAssetCategoryType_noMatchingCode_createCalledAndReturned() { + // Arrange + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + // Different code in the results + AssetCategoryType other = buildAssetCategoryType(UUID.randomUUID(), "INDUSTRY", "Industry"); + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(other)))); + + AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); + when(assetUniverseApi.createAssetCategoryType(request)).thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectNextMatches(result -> "SECTOR".equals(result.getCode())) + .verifyComplete(); + + verify(assetUniverseApi).createAssetCategoryType(request); + verify(assetUniverseApi, never()).updateAssetCategoryType(any(), any()); + } + + @Test + @DisplayName("update fails — onErrorResume swallows error, Mono.empty() returned") + void upsertAssetCategoryType_updateFails_errorSwallowedReturnsEmpty() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); + when(assetUniverseApi.updateAssetCategoryType(eq(existingUuid.toString()), eq(request))) + .thenReturn(Mono.error(new RuntimeException("update failed"))); + + // switchIfEmpty path stubbed so the mono completes rather than hanging + when(assetUniverseApi.createAssetCategoryType(request)).thenReturn(Mono.empty()); + + // Act & Assert — onErrorResume returns Mono.empty() + StepVerifier.create(service.upsertAssetCategoryType(request)) + .verifyComplete(); + } + + @Test + @DisplayName("createAssetCategoryType fails — error propagated") + void upsertAssetCategoryType_createFails_errorPropagated() { + // Arrange + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + + when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of()))); + when(assetUniverseApi.createAssetCategoryType(request)) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectErrorMatches(e -> e instanceof RuntimeException && "create failed".equals(e.getMessage())) + .verify(); + } + } + + // ========================================================================= + // createAssets + // ========================================================================= + + /** + * Tests for {@link InvestmentAssetUniverseService#createAssets(List)}. + * + *

Covers: + *

    + *
  • Null list → returns Flux.empty() without calling API
  • + *
  • Empty list → returns Flux.empty() without calling API
  • + *
  • Non-empty list → listAssetCategories called and each asset processed via getOrCreateAsset
  • + *
+ */ + @Nested + @DisplayName("createAssets") + class CreateAssetsTests { + + @Test + @DisplayName("null asset list — returns empty Flux without calling any API") + void createAssets_nullList_returnsEmptyFlux() { + // Act & Assert + StepVerifier.create(service.createAssets(null)) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetCategories(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("empty asset list — returns empty Flux without calling any API") + void createAssets_emptyList_returnsEmptyFlux() { + // Act & Assert + StepVerifier.create(service.createAssets(List.of())) + .verifyComplete(); + + verify(assetUniverseApi, never()).listAssetCategories(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("non-empty list — listAssetCategories called and each asset processed") + void createAssets_nonEmptyList_listCategoriesCalledAndAssetsProcessed() { + // Arrange + com.backbase.stream.investment.Asset assetReq = buildAsset(); + com.backbase.stream.investment.Asset created = buildAsset(); + + when(assetUniverseApi.listAssetCategories(isNull(), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of()))); + when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(notFound())); + when(investmentRestAssetUniverseService.createAsset(eq(assetReq), any())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.createAssets(List.of(assetReq))) + .expectNextCount(1) + .verifyComplete(); + + verify(assetUniverseApi).listAssetCategories(isNull(), isNull(), isNull(), isNull(), isNull(), isNull()); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Builds a 404 NOT_FOUND {@link WebClientResponseException}. + */ + private WebClientResponseException notFound() { + return WebClientResponseException.create( + HttpStatus.NOT_FOUND.value(), + "Not Found", + HttpHeaders.EMPTY, + null, + StandardCharsets.UTF_8 + ); + } + + /** + * Builds a stream {@link com.backbase.stream.investment.Asset} with fixed ISIN, market and currency. + */ + private com.backbase.stream.investment.Asset buildAsset() { + com.backbase.stream.investment.Asset asset = new com.backbase.stream.investment.Asset(); + asset.setIsin("ABC123"); + asset.setMarket("market"); + asset.setCurrency("USD"); + return asset; + } + + /** + * Builds a {@link MarketSpecialDayRequest} for the given market and date. + */ + private MarketSpecialDayRequest buildMarketSpecialDayRequest(String market, LocalDate date) { + MarketSpecialDayRequest request = new MarketSpecialDayRequest(); + request.setMarket(market); + request.setDate(date); + return request; + } + + /** + * Builds a {@link MarketSpecialDay} with the given uuid, market and date. + */ + private MarketSpecialDay buildMarketSpecialDay(UUID uuid, String market, LocalDate date) { + MarketSpecialDay day = new MarketSpecialDay(uuid); + day.setMarket(market); + day.setDate(date); + return day; + } + + /** + * Builds a {@link PaginatedMarketSpecialDayList} wrapping the given results. + */ + private PaginatedMarketSpecialDayList buildMarketSpecialDayPage(List results) { + PaginatedMarketSpecialDayList page = new PaginatedMarketSpecialDayList(); + page.setResults(new ArrayList<>(results)); + page.setCount(results.size()); + return page; + } + + /** + * Builds an {@link AssetCategoryEntry} with the given code, name and optional uuid. + */ + private AssetCategoryEntry buildAssetCategoryEntry(String code, String name, UUID uuid) { + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setCode(code); + entry.setName(name); + entry.setUuid(uuid); + return entry; + } + + /** + * Builds a v1 API {@link com.backbase.investment.api.service.v1.model.AssetCategory} + * with the given uuid and code. + */ + private com.backbase.investment.api.service.v1.model.AssetCategory buildApiAssetCategory(UUID uuid, String code) { + com.backbase.investment.api.service.v1.model.AssetCategory category = + new com.backbase.investment.api.service.v1.model.AssetCategory(uuid); + category.setCode(code); + return category; + } + + /** + * Builds a sync API {@link AssetCategory} with the given uuid. + */ + private AssetCategory buildSyncAssetCategory(UUID uuid) { + return new AssetCategory(uuid); + } + + /** + * Builds a {@link PaginatedAssetCategoryList} wrapping the given results. + */ + private PaginatedAssetCategoryList buildAssetCategoryPage( + List results) { + PaginatedAssetCategoryList page = new PaginatedAssetCategoryList(); + page.setResults(new ArrayList<>(results)); + page.setCount(results.size()); + return page; + } + + /** + * Builds an {@link AssetCategoryTypeRequest} with the given code and name. + */ + private AssetCategoryTypeRequest buildAssetCategoryTypeRequest(String code, String name) { + AssetCategoryTypeRequest request = new AssetCategoryTypeRequest(); + request.setCode(code); + request.setName(name); + return request; + } + + /** + * Builds an {@link AssetCategoryType} with the given uuid, code and name. + */ + private AssetCategoryType buildAssetCategoryType(UUID uuid, String code, String name) { + AssetCategoryType type = new AssetCategoryType(uuid); + type.setCode(code); + type.setName(name); + return type; + } + + /** + * Builds a {@link PaginatedAssetCategoryTypeList} wrapping the given results. + */ + private PaginatedAssetCategoryTypeList buildAssetCategoryTypePage(List results) { + PaginatedAssetCategoryTypeList page = new PaginatedAssetCategoryTypeList(); + page.setResults(new ArrayList<>(results)); + page.setCount(results.size()); + return page; } } \ No newline at end of file diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentCurrencyServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentCurrencyServiceTest.java new file mode 100644 index 000000000..555f361e2 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentCurrencyServiceTest.java @@ -0,0 +1,389 @@ +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.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.mockito.ArgumentCaptor; + +import com.backbase.investment.api.service.v1.CurrencyApi; +import com.backbase.investment.api.service.v1.model.Currency; +import com.backbase.investment.api.service.v1.model.CurrencyRequest; +import com.backbase.investment.api.service.v1.model.PaginatedCurrencyList; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests for {@link InvestmentCurrencyService}. + * + *

Tests are grouped by method under {@link Nested} classes. Each nested class covers a single + * public method, and each test covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom reduce boilerplate
  • + *
+ */ +@DisplayName("InvestmentCurrencyService") +class InvestmentCurrencyServiceTest { + + private CurrencyApi currencyApi; + private InvestmentCurrencyService service; + + @BeforeEach + void setUp() { + currencyApi = mock(CurrencyApi.class); + service = new InvestmentCurrencyService(currencyApi); + } + + // ========================================================================= + // upsertCurrencies + // ========================================================================= + + /** + * Tests for {@link InvestmentCurrencyService#upsertCurrencies(List)}. + * + *

Covers: + *

    + *
  • Empty input list → returns empty result list, no API calls made
  • + *
  • Currency not in existing list → createCurrency is called
  • + *
  • Currency already in existing list → updateCurrency is called
  • + *
  • Multiple currencies — mix of create and update paths
  • + *
  • Currency with null code → skipped (Mono.empty), absent from result
  • + *
  • Currency with blank code → skipped (Mono.empty), absent from result
  • + *
  • listCurrencies API fails → onErrorResume swallows, entry absent from result
  • + *
  • createCurrency fails → onErrorResume swallows, entry absent from result
  • + *
  • updateCurrency fails → onErrorResume swallows, entry absent from result
  • + *
  • Null entry in paginated results → Objects::nonNull filter, falls through to create
  • + *
  • All currencies fail → result list is empty
  • + *
  • CONTENT_RETRIEVE_LIMIT (100) is used as the limit argument to listCurrencies
  • + *
  • Paginated results list is null → treated as empty, falls through to create
  • + *
+ */ + @Nested + @DisplayName("upsertCurrencies") + class UpsertCurrenciesTests { + + @Test + @DisplayName("empty input list — returns empty result list without calling API") + void upsertCurrencies_emptyList_returnsEmptyListWithoutCallingApi() { + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(currencyApi, never()).listCurrencies(any(), any()); + verify(currencyApi, never()).createCurrency(any()); + verify(currencyApi, never()).updateCurrency(any(), any()); + } + + @Test + @DisplayName("currency code not in existing list — createCurrency is called and currency returned") + void upsertCurrencies_currencyNotExists_createCurrencyCalledAndReturned() { + // Arrange + Currency currency = buildCurrency("USD", "US Dollar", "$"); + + PaginatedCurrencyList emptyPage = buildPage(List.of()); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(emptyPage)); + + Currency created = buildCurrency("USD", "US Dollar", "$"); + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(result -> result.size() == 1 && "USD".equals(result.get(0).getCode())) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CurrencyRequest.class); + verify(currencyApi).createCurrency(captor.capture()); + verify(currencyApi, never()).updateCurrency(any(), any()); + assertThat(captor.getValue().getCode()).isEqualTo("USD"); + assertThat(captor.getValue().getName()).isEqualTo("US Dollar"); + assertThat(captor.getValue().getSymbol()).isEqualTo("$"); + } + + @Test + @DisplayName("currency code already exists — updateCurrency is called and currency returned") + void upsertCurrencies_currencyAlreadyExists_updateCurrencyCalledAndReturned() { + // Arrange + Currency currency = buildCurrency("EUR", "Euro", "€"); + + Currency existingEntry = buildCurrency("EUR", "Euro", "€"); + PaginatedCurrencyList page = buildPage(List.of(existingEntry)); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(page)); + + Currency updated = buildCurrency("EUR", "Euro Updated", "€"); + when(currencyApi.updateCurrency(eq("EUR"), any(CurrencyRequest.class))) + .thenReturn(Mono.just(updated)); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(result -> result.size() == 1 && "EUR".equals(result.get(0).getCode())) + .verifyComplete(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CurrencyRequest.class); + verify(currencyApi).updateCurrency(eq("EUR"), captor.capture()); + verify(currencyApi, never()).createCurrency(any()); + assertThat(captor.getValue().getCode()).isEqualTo("EUR"); + assertThat(captor.getValue().getName()).isEqualTo("Euro"); + assertThat(captor.getValue().getSymbol()).isEqualTo("€"); + } + + @Test + @DisplayName("multiple currencies — mix of create and update, both results collected") + void upsertCurrencies_multipleCurrencies_mixedCreateAndUpdate_allCollected() { + // Arrange + Currency usdCurrency = buildCurrency("USD", "US Dollar", "$"); + Currency eurCurrency = buildCurrency("EUR", "Euro", "€"); + + // EUR already exists, USD does not + Currency existingEur = buildCurrency("EUR", "Euro", "€"); + PaginatedCurrencyList page = buildPage(List.of(existingEur)); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(page)); + + Currency createdUsd = buildCurrency("USD", "US Dollar", "$"); + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.just(createdUsd)); + + Currency updatedEur = buildCurrency("EUR", "Euro", "€"); + when(currencyApi.updateCurrency(eq("EUR"), any(CurrencyRequest.class))) + .thenReturn(Mono.just(updatedEur)); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(usdCurrency, eurCurrency))) + .expectNextMatches(result -> result.size() == 2) + .verifyComplete(); + + verify(currencyApi).createCurrency(any(CurrencyRequest.class)); + verify(currencyApi).updateCurrency(eq("EUR"), any(CurrencyRequest.class)); + } + + @Test + @DisplayName("currency with null code — validation skips it, entry absent from result") + void upsertCurrencies_nullCode_skippedAndAbsentFromResult() { + // Arrange + Currency currency = buildCurrency(null, "Unknown", "?"); + + // Act & Assert — skipped via Mono.empty(), so result list is empty + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(currencyApi, never()).listCurrencies(any(), any()); + verify(currencyApi, never()).createCurrency(any()); + verify(currencyApi, never()).updateCurrency(any(), any()); + } + + @Test + @DisplayName("currency with blank code — validation skips it, entry absent from result") + void upsertCurrencies_blankCode_skippedAndAbsentFromResult() { + // Arrange + Currency currency = buildCurrency(" ", "Unknown", "?"); + + // Act & Assert — blank code fails the isBlank() check → Mono.empty() + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(currencyApi, never()).listCurrencies(any(), any()); + verify(currencyApi, never()).createCurrency(any()); + verify(currencyApi, never()).updateCurrency(any(), any()); + } + + @Test + @DisplayName("listCurrencies API fails — onErrorResume swallows error, entry absent from result") + void upsertCurrencies_listCurrenciesFails_errorSwallowed_entryAbsentFromResult() { + // Arrange + Currency currency = buildCurrency("GBP", "Pound Sterling", "£"); + + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.error(new RuntimeException("API unavailable"))); + + // Act & Assert — onErrorResume returns Mono.empty(), collected list is empty + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(currencyApi, never()).createCurrency(any()); + verify(currencyApi, never()).updateCurrency(any(), any()); + } + + @Test + @DisplayName("createCurrency fails — onErrorResume swallows error, entry absent from result") + void upsertCurrencies_createCurrencyFails_errorSwallowed_entryAbsentFromResult() { + // Arrange + Currency currency = buildCurrency("JPY", "Japanese Yen", "¥"); + + PaginatedCurrencyList emptyPage = buildPage(List.of()); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(emptyPage)); + + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } + + @Test + @DisplayName("updateCurrency fails — onErrorResume swallows error, entry absent from result") + void upsertCurrencies_updateCurrencyFails_errorSwallowed_entryAbsentFromResult() { + // Arrange + Currency currency = buildCurrency("CHF", "Swiss Franc", "Fr"); + + Currency existingEntry = buildCurrency("CHF", "Swiss Franc", "Fr"); + PaginatedCurrencyList page = buildPage(List.of(existingEntry)); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(page)); + + when(currencyApi.updateCurrency(eq("CHF"), any(CurrencyRequest.class))) + .thenReturn(Mono.error(new RuntimeException("update failed"))); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } + + @Test + @DisplayName("paginated results contain a null entry — null filtered out, falls through to create path") + void upsertCurrencies_nullEntryInPageResults_filteredOut_createPathTaken() { + // Arrange — results list has a null element; Objects::nonNull removes it, code not matched + Currency currency = buildCurrency("AUD", "Australian Dollar", "A$"); + + // Use a mutable list so we can include null + List resultsWithNull = new ArrayList<>(); + resultsWithNull.add(null); + PaginatedCurrencyList page = buildPage(resultsWithNull); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(page)); + + Currency created = buildCurrency("AUD", "Australian Dollar", "A$"); + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(result -> result.size() == 1 && "AUD".equals(result.get(0).getCode())) + .verifyComplete(); + + verify(currencyApi).createCurrency(any(CurrencyRequest.class)); + verify(currencyApi, never()).updateCurrency(any(), any()); + } + + @Test + @DisplayName("all currencies fail — result list is empty") + void upsertCurrencies_allCurrenciesFail_resultListIsEmpty() { + // Arrange — two currencies both fail on listCurrencies + Currency usd = buildCurrency("USD", "US Dollar", "$"); + Currency eur = buildCurrency("EUR", "Euro", "€"); + + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.error(new RuntimeException("service down"))); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(usd, eur))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } + + @Test + @DisplayName("CONTENT_RETRIEVE_LIMIT (100) is used as the limit argument to listCurrencies") + void upsertCurrencies_usesContentRetrieveLimit_asListCurrenciesLimit() { + // Arrange + Currency currency = buildCurrency("CAD", "Canadian Dollar", "C$"); + + PaginatedCurrencyList emptyPage = buildPage(List.of()); + when(currencyApi.listCurrencies(100, 0)) + .thenReturn(Mono.just(emptyPage)); + + Currency created = buildCurrency("CAD", "Canadian Dollar", "C$"); + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertCurrencies(List.of(currency))) + .expectNextMatches(result -> result.size() == 1) + .verifyComplete(); + + // Explicitly verifies the constant value 100 is passed as the limit + verify(currencyApi).listCurrencies(100, 0); + } + + @Test + @DisplayName("single currency with null code mixed with valid currency — only valid currency in result") + void upsertCurrencies_mixedNullAndValidCode_onlyValidCurrencyInResult() { + // Arrange + Currency invalidCurrency = buildCurrency(null, "Unknown", "?"); + Currency validCurrency = buildCurrency("SEK", "Swedish Krona", "kr"); + + PaginatedCurrencyList emptyPage = buildPage(List.of()); + when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) + .thenReturn(Mono.just(emptyPage)); + + Currency created = buildCurrency("SEK", "Swedish Krona", "kr"); + when(currencyApi.createCurrency(any(CurrencyRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert — invalid skipped, valid created → result has exactly one entry + StepVerifier.create(service.upsertCurrencies(List.of(invalidCurrency, validCurrency))) + .expectNextMatches(result -> result.size() == 1 && "SEK".equals(result.get(0).getCode())) + .verifyComplete(); + + verify(currencyApi).createCurrency(any(CurrencyRequest.class)); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Builds a {@link Currency} with the given field values. + * + * @param code ISO currency code (may be {@code null} for invalid-input tests) + * @param name human-readable currency name + * @param symbol currency symbol + * @return a fully populated {@link Currency} + */ + private Currency buildCurrency(String code, String name, String symbol) { + Currency currency = new Currency(); + currency.setCode(code); + currency.setName(name); + currency.setSymbol(symbol); + return currency; + } + + /** + * Builds a {@link PaginatedCurrencyList} whose {@code results} list is the given list. + * + * @param results list of {@link Currency} entries (may contain {@code null} elements) + * @return a {@link PaginatedCurrencyList} wrapping the provided results + */ + private PaginatedCurrencyList buildPage(List results) { + PaginatedCurrencyList page = new PaginatedCurrencyList(); + page.setResults(results); + page.setCount(results.size()); + return page; + } +} + diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java index 1180ab768..41e6c0827 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentIntradayAssetPriceServiceTest.java @@ -2,53 +2,631 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.backbase.investment.api.service.v1.AssetUniverseApi; +import com.backbase.investment.api.service.v1.model.GroupResult; +import com.backbase.investment.api.service.v1.model.OASCreatePriceRequest; +import com.backbase.investment.api.service.v1.model.TypeEnum; +import com.backbase.stream.investment.model.AssetWithMarketAndLatestPrice; +import com.backbase.stream.investment.model.ExpandedLatestPrice; +import com.backbase.stream.investment.model.ExpandedMarket; +import com.backbase.stream.investment.model.PaginatedExpandedAssetList; import com.backbase.stream.investment.service.InvestmentIntradayAssetPriceService.Ohlc; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; /** - * Unit tests covering deterministic parts of the intraday price generator. + * Unit tests for {@link InvestmentIntradayAssetPriceService}. * - *

Focus is on {@link InvestmentIntradayAssetPriceService#generateIntradayOhlc(Double)} which is - * deterministic given a provided Random seed; tests assert structural invariants and rounding. + *

Tests are grouped by method under {@link Nested} classes. Each nested class covers a single + * public method, and each test covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom reduce boilerplate
  • + *
*/ +@DisplayName("InvestmentIntradayAssetPriceService") class InvestmentIntradayAssetPriceServiceTest { - @Test - void generateIntradayOhlc_shouldValidateInput() { - assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(null)) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(0.0)) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(-1.0)) - .isInstanceOf(IllegalArgumentException.class); + private AssetUniverseApi assetUniverseApi; + private InvestmentIntradayAssetPriceService service; + + @BeforeEach + void setUp() { + assetUniverseApi = mock(AssetUniverseApi.class); + service = new InvestmentIntradayAssetPriceService(assetUniverseApi); + } + + // ========================================================================= + // generateIntradayOhlc + // ========================================================================= + + /** + * Tests for {@link InvestmentIntradayAssetPriceService#generateIntradayOhlc(Double)}. + * + *

Covers: + *

    + *
  • null previousClose → IllegalArgumentException thrown
  • + *
  • zero previousClose → IllegalArgumentException thrown
  • + *
  • negative previousClose → IllegalArgumentException thrown
  • + *
  • valid previousClose → all OHLC values are positive and correctly rounded to 6 decimal places
  • + *
  • high >= max(open, close) and low <= min(open, close) invariant always holds
  • + *
  • open and close are both within [low, high]
  • + *
  • very small previousClose (0.000001) → all values remain positive
  • + *
  • very large previousClose (1_000_000.0) → values scale proportionally
  • + *
  • Ohlc record accessors return the correct component values
  • + *
+ */ + @Nested + @DisplayName("generateIntradayOhlc") + class GenerateIntradayOhlcTests { + + @Test + @DisplayName("null previousClose — IllegalArgumentException thrown") + void generateIntradayOhlc_nullPreviousClose_throwsIllegalArgumentException() { + assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Previous close must be positive"); + } + + @Test + @DisplayName("zero previousClose — IllegalArgumentException thrown") + void generateIntradayOhlc_zeroPreviousClose_throwsIllegalArgumentException() { + assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Previous close must be positive"); + } + + @Test + @DisplayName("negative previousClose — IllegalArgumentException thrown") + void generateIntradayOhlc_negativePreviousClose_throwsIllegalArgumentException() { + assertThatThrownBy(() -> InvestmentIntradayAssetPriceService.generateIntradayOhlc(-1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Previous close must be positive"); + } + + @RepeatedTest(10) + @DisplayName("valid previousClose — all OHLC values positive and rounded to 6 decimal places") + void generateIntradayOhlc_validPreviousClose_producesValidOhlcStructure() { + // Arrange + double previous = 100.0; + + // Act + Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(previous); + + // Assert — all values positive + assertThat(ohlc.open()).isGreaterThan(0.0); + assertThat(ohlc.high()).isGreaterThan(0.0); + assertThat(ohlc.low()).isGreaterThan(0.0); + assertThat(ohlc.close()).isGreaterThan(0.0); + + // Assert — high >= max(open,close), low <= min(open,close) + assertThat(ohlc.high()).isGreaterThanOrEqualTo(Math.max(ohlc.open(), ohlc.close())); + assertThat(ohlc.low()).isLessThanOrEqualTo(Math.min(ohlc.open(), ohlc.close())); + + // Assert — rounded to 6 decimal places + assertThat(Math.abs(Math.round(ohlc.open() * 1_000_000.0) - ohlc.open() * 1_000_000.0)) + .isLessThan(1e-6); + assertThat(Math.abs(Math.round(ohlc.high() * 1_000_000.0) - ohlc.high() * 1_000_000.0)) + .isLessThan(1e-6); + assertThat(Math.abs(Math.round(ohlc.low() * 1_000_000.0) - ohlc.low() * 1_000_000.0)) + .isLessThan(1e-6); + assertThat(Math.abs(Math.round(ohlc.close() * 1_000_000.0) - ohlc.close() * 1_000_000.0)) + .isLessThan(1e-6); + } + + @RepeatedTest(20) + @DisplayName("high >= low invariant always holds regardless of bullish/bearish direction") + void generateIntradayOhlc_highAlwaysGreaterThanOrEqualToLow() { + // Act + Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(50.0); + + // Assert + assertThat(ohlc.high()).isGreaterThanOrEqualTo(ohlc.low()); + } + + @RepeatedTest(20) + @DisplayName("open and close are both within [low, high]") + void generateIntradayOhlc_openAndCloseWithinLowHighRange() { + // Act + Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(200.0); + + // Assert + assertThat(ohlc.open()).isBetween(ohlc.low(), ohlc.high()); + assertThat(ohlc.close()).isBetween(ohlc.low(), ohlc.high()); + } + + @RepeatedTest(10) + @DisplayName("very small previousClose (0.000001) — all OHLC values are positive") + void generateIntradayOhlc_verySmallPreviousClose_allValuesPositive() { + // Arrange + double previous = 0.000001; + + // Act + Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(previous); + + // Assert + assertThat(ohlc.open()).isGreaterThan(0.0); + assertThat(ohlc.high()).isGreaterThan(0.0); + assertThat(ohlc.low()).isGreaterThan(0.0); + assertThat(ohlc.close()).isGreaterThan(0.0); + } + + @RepeatedTest(10) + @DisplayName("very large previousClose (1_000_000.0) — values scale proportionally and remain positive") + void generateIntradayOhlc_veryLargePreviousClose_valuesScaleAndRemainPositive() { + // Arrange + double previous = 1_000_000.0; + + // Act + Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(previous); + + // Assert + assertThat(ohlc.open()).isGreaterThan(900_000.0); + assertThat(ohlc.high()).isGreaterThan(0.0); + assertThat(ohlc.low()).isGreaterThan(0.0); + assertThat(ohlc.close()).isGreaterThan(900_000.0); + } + + @Test + @DisplayName("Ohlc record — accessors return the correct component values") + void ohlcRecord_accessorsReturnCorrectValues() { + // Arrange + Ohlc ohlc = new Ohlc(1.0, 2.0, 0.5, 1.5); + + // Assert + assertThat(ohlc.open()).isEqualTo(1.0); + assertThat(ohlc.high()).isEqualTo(2.0); + assertThat(ohlc.low()).isEqualTo(0.5); + assertThat(ohlc.close()).isEqualTo(1.5); + } + } + + // ========================================================================= + // ingestIntradayPrices + // ========================================================================= + + /** + * Tests for {@link InvestmentIntradayAssetPriceService#ingestIntradayPrices()}. + * + *

Covers: + *

    + *
  • Zero assets (count == 0) → returns empty list, bulkCreate never called
  • + *
  • Asset with null expandedLatestPrice → skipped, bulkCreate never called
  • + *
  • Asset with null previousClosePrice inside expandedLatestPrice → skipped, bulkCreate never called
  • + *
  • Single asset with valid latest price → bulkCreate called, GroupResult returned and flattened
  • + *
  • Single valid asset → each request has type INTRADAY, correct asset uuid, and exactly 15 candles
  • + *
  • Multiple assets with valid latest prices → results from all assets are flattened into one list
  • + *
  • Mixed assets (one valid, one with null price) → only valid asset processed
  • + *
  • bulkCreate fails with WebClientResponseException → error swallowed, pipeline completes
  • + *
  • bulkCreate fails with generic RuntimeException → error swallowed, pipeline completes
  • + *
  • listAssetsWithResponseSpec fails with WebClientResponseException → error propagated
  • + *
  • listAssetsWithResponseSpec fails with generic RuntimeException → error propagated
  • + *
+ */ + @Nested + @DisplayName("ingestIntradayPrices") + class IngestIntradayPricesTests { + + @Test + @DisplayName("zero assets (count == 0) — returns empty list, bulkCreate never called") + void ingestIntradayPrices_zeroAssets_returnsEmptyList() { + // Arrange + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList emptyPage = PaginatedExpandedAssetList.builder() + .count(0) + .results(List.of()) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(emptyPage)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateIntradayAssetPrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("asset with null expandedLatestPrice — skipped, bulkCreate never called") + void ingestIntradayPrices_assetWithNullLatestPrice_skipped() { + // Arrange + AssetWithMarketAndLatestPrice assetWithNullLatestPrice = + new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), null); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(assetWithNullLatestPrice)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateIntradayAssetPrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("asset with null previousClosePrice — skipped, bulkCreate never called") + void ingestIntradayPrices_assetWithNullPreviousClosePrice_skipped() { + // Arrange + ExpandedLatestPrice latestPriceWithNullClose = + new ExpandedLatestPrice(100.0, OffsetDateTime.now(), 99.0, 101.0, 98.0, null); + AssetWithMarketAndLatestPrice asset = + new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), latestPriceWithNullClose); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(asset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + + verify(assetUniverseApi, never()).bulkCreateIntradayAssetPrice(any(), any(), any(), any()); + } + + @Test + @DisplayName("single asset with valid latest price — bulkCreate called and GroupResult returned") + void ingestIntradayPrices_singleValidAsset_bulkCreateCalledAndResultReturned() { + // Arrange + UUID assetUuid = UUID.randomUUID(); + AssetWithMarketAndLatestPrice asset = buildValidAsset(assetUuid, 150.0); + GroupResult groupResult = buildGroupResult(); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(asset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> { + assertThat(result).hasSize(1); + assertThat(result.getFirst().getUuid()).isEqualTo(groupResult.getUuid()); + }) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("single valid asset — 15 requests submitted, each with type INTRADAY and correct asset uuid") + void ingestIntradayPrices_singleValidAsset_requestsHaveCorrectTypeUuidAnd15Candles() { + // Arrange + UUID assetUuid = UUID.randomUUID(); + AssetWithMarketAndLatestPrice asset = buildValidAsset(assetUuid, 50.0); + GroupResult groupResult = buildGroupResult(); + + var responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(asset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenAnswer(invocation -> { + List requests = invocation.getArgument(0); + assertThat(requests).hasSize(15); + requests.forEach(req -> { + assertThat(req.getType()).isEqualTo(TypeEnum.INTRADAY); + assertThat(req.getAsset()) + .containsEntry("uuid", assetUuid.toString()); + }); + return Flux.just(groupResult); + }); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).hasSize(1)) + .verifyComplete(); + } + + @Test + @DisplayName("multiple assets with valid latest prices — results from all assets are flattened") + void ingestIntradayPrices_multipleValidAssets_resultsFlattened() { + // Arrange + AssetWithMarketAndLatestPrice asset1 = buildValidAsset(UUID.randomUUID(), 100.0); + AssetWithMarketAndLatestPrice asset2 = buildValidAsset(UUID.randomUUID(), 200.0); + GroupResult groupResult1 = buildGroupResult(); + GroupResult groupResult2 = buildGroupResult(); + + var responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(2) + .results(List.of(asset1, asset2)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) // bodyToMono with custom type + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult1)) + .thenReturn(Flux.just(groupResult2)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).hasSize(2)) + .verifyComplete(); + } + + @Test + @DisplayName("mixed assets — one valid, one with null latest price — only valid asset processed") + void ingestIntradayPrices_mixedAssets_onlyValidAssetProcessed() { + // Arrange + AssetWithMarketAndLatestPrice validAsset = buildValidAsset(UUID.randomUUID(), 300.0); + AssetWithMarketAndLatestPrice invalidAsset = + new AssetWithMarketAndLatestPrice(UUID.randomUUID(), buildExpandedMarket(), null); + GroupResult groupResult = buildGroupResult(); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(2) + .results(List.of(validAsset, invalidAsset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.just(groupResult)); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).hasSize(1)) + .verifyComplete(); + + verify(assetUniverseApi).bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("bulkCreate fails with WebClientResponseException — error swallowed, pipeline completes") + void ingestIntradayPrices_bulkCreateFailsWithWebClientException_errorSwallowedPipelineCompletes() { + // Arrange + AssetWithMarketAndLatestPrice asset = buildValidAsset(UUID.randomUUID(), 120.0); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(asset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.error(notFound())); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("bulkCreate fails with generic RuntimeException — error swallowed, pipeline completes") + void ingestIntradayPrices_bulkCreateFailsWithRuntimeException_errorSwallowedPipelineCompletes() { + // Arrange + AssetWithMarketAndLatestPrice asset = buildValidAsset(UUID.randomUUID(), 75.0); + + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + PaginatedExpandedAssetList page = PaginatedExpandedAssetList.builder() + .count(1) + .results(List.of(asset)) + .build(); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.just(page)); + when(assetUniverseApi.bulkCreateIntradayAssetPrice(any(), isNull(), isNull(), isNull())) + .thenReturn(Flux.error(new RuntimeException("unexpected failure"))); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + } + + @Test + @DisplayName("listAssetsWithResponseSpec fails with WebClientResponseException — error propagated") + void ingestIntradayPrices_listAssetsFails_webClientError_errorPropagated() { + // Arrange + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.error(notFound())); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .expectErrorMatches(e -> e instanceof WebClientResponseException + && ((WebClientResponseException) e).getStatusCode() == HttpStatus.NOT_FOUND) + .verify(); + } + + @Test + @DisplayName("listAssetsWithResponseSpec fails with generic RuntimeException — error propagated") + void ingestIntradayPrices_listAssetsFails_runtimeError_errorPropagated() { + // Arrange + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + + when(assetUniverseApi.listAssetsWithResponseSpec( + isNull(), isNull(), isNull(), isNull(), + any(), isNull(), isNull(), any(), + isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedAssetList.class)) + .thenReturn(Mono.error(new RuntimeException("connection refused"))); + + // Act & Assert + StepVerifier.create(service.ingestIntradayPrices()) + .expectErrorMatches(e -> e instanceof RuntimeException + && "connection refused".equals(e.getMessage())) + .verify(); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Builds a 404 NOT_FOUND {@link WebClientResponseException}. + */ + private WebClientResponseException notFound() { + return WebClientResponseException.create( + HttpStatus.NOT_FOUND.value(), + "Not Found", + HttpHeaders.EMPTY, + null, + StandardCharsets.UTF_8 + ); + } + + /** + * Builds an {@link ExpandedMarket} with a fixed NYSE session start time. + */ + private ExpandedMarket buildExpandedMarket() { + return new ExpandedMarket( + "NYSE", + "New York Stock Exchange", + true, + OffsetDateTime.of(2026, 3, 12, 9, 30, 0, 0, ZoneOffset.UTC), + OffsetDateTime.of(2026, 3, 12, 16, 0, 0, 0, ZoneOffset.UTC), + null + ); } - @RepeatedTest(10) - void generateIntradayOhlc_shouldProduceValidOhlcStructure() { - double previous = 100.0; - Ohlc ohlc = InvestmentIntradayAssetPriceService.generateIntradayOhlc(previous); - - double open = ohlc.open(); - double high = ohlc.high(); - double low = ohlc.low(); - double close = ohlc.close(); - - // Basic sanity - assertThat(open).isGreaterThan(0.0); - assertThat(high).isGreaterThan(0.0); - assertThat(low).isGreaterThan(0.0); - assertThat(close).isGreaterThan(0.0); - - // High must be >= max(open, close), low must be <= min(open, close) - assertThat(high).isGreaterThanOrEqualTo(Math.max(open, close)); - assertThat(low).isLessThanOrEqualTo(Math.min(open, close)); - - // Values should be rounded to 6 decimal places: value * 1e6 should be near-integer - assertThat(Math.abs(Math.round(open * 1_000_000.0) - open * 1_000_000.0)).isLessThan(1e-6); - assertThat(Math.abs(Math.round(high * 1_000_000.0) - high * 1_000_000.0)).isLessThan(1e-6); - assertThat(Math.abs(Math.round(low * 1_000_000.0) - low * 1_000_000.0)).isLessThan(1e-6); - assertThat(Math.abs(Math.round(close * 1_000_000.0) - close * 1_000_000.0)).isLessThan(1e-6); + /** + * Builds a valid {@link AssetWithMarketAndLatestPrice} with the given uuid and previousClosePrice. + */ + private AssetWithMarketAndLatestPrice buildValidAsset(UUID uuid, double previousClosePrice) { + ExpandedLatestPrice latestPrice = new ExpandedLatestPrice( + previousClosePrice, + OffsetDateTime.of(2026, 3, 11, 16, 0, 0, 0, ZoneOffset.UTC), + previousClosePrice * 0.99, + previousClosePrice * 1.02, + previousClosePrice * 0.98, + previousClosePrice + ); + return new AssetWithMarketAndLatestPrice(uuid, buildExpandedMarket(), latestPrice); } -} \ No newline at end of file + + /** + * Builds a {@link GroupResult} with a random UUID and status "SUCCESS". + */ + private GroupResult buildGroupResult() { + return new GroupResult(UUID.randomUUID(), "SUCCESS", List.of()); + } +} + + + + + + + + + diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java new file mode 100644 index 000000000..fdf8fe477 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java @@ -0,0 +1,754 @@ +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.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.v1.FinancialAdviceApi; +import com.backbase.investment.api.service.v1.model.OASModelPortfolioRequestDataRequest; +import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse; +import com.backbase.investment.api.service.v1.model.PaginatedOASModelPortfolioResponseList; +import com.backbase.stream.investment.Allocation; +import com.backbase.stream.investment.InvestmentData; +import com.backbase.stream.investment.ModelAsset; +import com.backbase.stream.investment.ModelPortfolio; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit test suite for {@link InvestmentModelPortfolioService}. + * + *

This class verifies the complete orchestration logic of the service, which drives + * the model portfolio ingestion pipeline through the following stages: + *

    + *
  1. List existing model portfolios by name and risk level
  2. + *
  3. If found, patch the existing model portfolio
  4. + *
  5. If not found, create a new model portfolio
  6. + *
+ * + *

Test strategy: + *

    + *
  • Each pipeline stage is tested in isolation via a dedicated {@code @Nested} class.
  • + *
  • Happy-path, empty-collection, null-field, and error scenarios are covered for every stage.
  • + *
  • All reactive assertions use Project Reactor's {@link StepVerifier}.
  • + *
+ * + *

Mocked dependencies: + *

    + *
  • {@link FinancialAdviceApi} – list model portfolios
  • + *
  • {@link CustomIntegrationApiService} – create / patch model portfolios
  • + *
+ */ +@SuppressWarnings({"deprecation", "removal"}) +class InvestmentModelPortfolioServiceTest { + + @Mock + private FinancialAdviceApi financialAdviceApi; + + @Mock + private CustomIntegrationApiService customIntegrationApiService; + + private InvestmentModelPortfolioService service; + + private AutoCloseable mocks; + + /** + * Opens Mockito annotations and constructs the service under test before each test. + */ + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + service = new InvestmentModelPortfolioService(financialAdviceApi, customIntegrationApiService); + } + + /** + * Closes Mockito mocks after each test to release resources. + */ + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + // ========================================================================= + // upsertModels – top-level flux orchestration + // ========================================================================= + + /** + * Tests for the {@link InvestmentModelPortfolioService#upsertModels} entry-point + * that iterates over the model portfolio list in {@link InvestmentData}. + */ + @Nested + @DisplayName("upsertModels") + class UpsertModelsTests { + + /** + * Verifies that when {@code investmentData.getModelPortfolios()} is {@code null}, + * {@code Objects.requireNonNullElse} substitutes an empty list and the flux + * completes without emitting any items or calling any downstream API. + */ + @Test + @DisplayName("should emit nothing and call no API when modelPortfolios is null") + void upsertModels_nullModelPortfolios_emitsNothing() { + // Arrange + InvestmentData data = InvestmentData.builder() + .modelPortfolios(null) + .build(); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .verifyComplete(); + + verify(financialAdviceApi, never()).listModelPortfolio( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + verify(customIntegrationApiService, never()).createModelPortfolioRequestCreation( + any(), any(), any(), any(), any()); + } + + /** + * Verifies that when {@code investmentData.getModelPortfolios()} is an empty list, + * the flux completes without emitting any items or calling any downstream API. + */ + @Test + @DisplayName("should emit nothing and call no API when modelPortfolios is empty") + void upsertModels_emptyModelPortfolios_emitsNothing() { + // Arrange + InvestmentData data = InvestmentData.builder() + .modelPortfolios(Collections.emptyList()) + .build(); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .verifyComplete(); + + verify(financialAdviceApi, never()).listModelPortfolio( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + /** + * Verifies that a single model portfolio with allocations is processed correctly: + * the allocation is mapped to {@code OASAssetModelPortfolioRequestRequest}, + * no existing portfolio is found, a new one is created, and the returned UUID + * is set on the template. + */ + @Test + @DisplayName("should create new model portfolio and set UUID on template when none exists") + void upsertModels_singlePortfolio_noExisting_createsAndSetsUuid() { + // Arrange + UUID expectedUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Conservative", 3); + OASModelPortfolioResponse created = buildResponse(expectedUuid, "Conservative", 3); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> { + assertThat(response.getUuid()).isEqualTo(expectedUuid); + assertThat(response.getName()).isEqualTo("Conservative"); + }) + .verifyComplete(); + + assertThat(template.getUuid()).isEqualTo(expectedUuid); + } + + /** + * Verifies that when an existing model portfolio is found (by name and risk level), + * the service patches it instead of creating a new one, and returns the patched response. + */ + @Test + @DisplayName("should patch existing model portfolio when one match is found") + void upsertModels_singlePortfolio_existingFound_patches() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Balanced", 5); + stubListReturnsOne("Balanced", 5, existing); + + OASModelPortfolioResponse patched = buildResponse(existingUuid, "Balanced", 5); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(existingUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(response -> assertThat(response.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(customIntegrationApiService, never()).createModelPortfolioRequestCreation( + any(), any(), any(), any(), any()); + } + + /** + * Verifies that when multiple model portfolios exist in the list, all are processed + * and each emits a response, so the flux emits one item per input portfolio. + * Uses {@code collectList()} to avoid ordering dependency from {@code flatMap}. + */ + @Test + @DisplayName("should process all model portfolios and emit one response per entry") + void upsertModels_multiplePortfolios_processesAll() { + // Arrange + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + ModelPortfolio t1 = buildModelPortfolio("Conservative", 3, 0.1); + ModelPortfolio t2 = buildModelPortfolio("Aggressive", 8, 0.05); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(t1, t2)) + .build(); + + stubListReturnsEmpty("Conservative", 3); + stubListReturnsEmpty("Aggressive", 8); + + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn( + Mono.just(buildResponse(uuid1, "Conservative", 3)), + Mono.just(buildResponse(uuid2, "Aggressive", 8))); + + // Act & Assert — collect to list so ordering from flatMap does not matter + StepVerifier.create(service.upsertModels(data).collectList()) + .assertNext(responses -> { + assertThat(responses).hasSize(2); + assertThat(responses).extracting(OASModelPortfolioResponse::getUuid) + .containsExactlyInAnyOrder(uuid1, uuid2); + }) + .verifyComplete(); + } + + /** + * Verifies that when the downstream create call fails, the error is propagated + * through the flux to the subscriber. + */ + @Test + @DisplayName("should propagate error from create when API call fails") + void upsertModels_createFails_propagatesError() { + // Arrange + ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Conservative", 3); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.error(new RuntimeException("create failed"))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(RuntimeException.class) + .verify(); + } + + /** + * Verifies that allocation fields (asset map, weight) from the {@link ModelPortfolio} + * template are correctly mapped into the {@link OASModelPortfolioRequestDataRequest} + * sent to the create API. + */ + @Test + @DisplayName("should correctly map allocation and cashWeight from template to API request") + void upsertModels_allocationMapping_correctlyMapsFields() { + // Arrange + UUID expectedUuid = UUID.randomUUID(); + ModelAsset asset = new ModelAsset("US1234567890", "XNAS", "USD"); + Allocation allocation = new Allocation(asset, 0.75); + ModelPortfolio template = ModelPortfolio.builder() + .name("Growth") + .riskLevel(7) + .cashWeight(0.25) + .allocations(List.of(allocation)) + .build(); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Growth", 7); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OASModelPortfolioRequestDataRequest.class); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), requestCaptor.capture(), isNull())) + .thenReturn(Mono.just(buildResponse(expectedUuid, "Growth", 7))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> assertThat(r.getUuid()).isEqualTo(expectedUuid)) + .verifyComplete(); + + OASModelPortfolioRequestDataRequest captured = requestCaptor.getValue(); + assertThat(captured.getName()).isEqualTo("Growth"); + assertThat(captured.getRiskLevel()).isEqualTo(7); + assertThat(captured.getCashWeight()).isEqualTo(0.25); + assertThat(captured.getAllocation()).hasSize(1); + assertThat(captured.getAllocation().getFirst().getWeight()).isEqualTo(0.75); + assertThat(captured.getAllocation().getFirst().getAsset()) + .containsEntry("isin", "US1234567890") + .containsEntry("market", "XNAS") + .containsEntry("currency", "USD"); + } + } + + // ========================================================================= + // listExistingModelPortfolios – internal lookup branch logic + // ========================================================================= + + /** + * Tests for the internal {@code listExistingModelPortfolios} logic, exercised + * indirectly through {@code upsertModels}. + */ + @Nested + @DisplayName("listExistingModelPortfolios") + class ListExistingModelPortfoliosTests { + + /** + * Verifies that when the API returns a {@code PaginatedOASModelPortfolioResponseList} + * with an empty results list, the code treats it as "not found" and creates new. + */ + @Test + @DisplayName("should create new portfolio when list response has empty results") + void listExisting_emptyResults_createsNew() { + // Arrange + UUID expectedUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Conservative", 2, 0.15); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Conservative", 2); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(buildResponse(expectedUuid, "Conservative", 2))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> assertThat(r.getUuid()).isEqualTo(expectedUuid)) + .verifyComplete(); + } + + /** + * Verifies that when the list API returns exactly one match, the first (and only) + * result is used to patch, and create is never called. + */ + @Test + @DisplayName("should patch using first result when exactly one match is found") + void listExisting_oneResult_patchesFirstResult() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Moderate", 5, 0.3); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Moderate", 5); + stubListReturnsOne("Moderate", 5, existing); + OASModelPortfolioResponse patched = buildResponse(existingUuid, "Moderate", 5); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(existingUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> assertThat(r.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(customIntegrationApiService, never()).createModelPortfolioRequestCreation( + any(), any(), any(), any(), any()); + } + + /** + * Verifies that when the list API returns more than one match, the first result + * is still used to patch (warn-and-use-first behaviour) and create is never called. + */ + @Test + @DisplayName("should patch first result and not create when multiple matches are found") + void listExisting_multipleResults_patchesFirstResult() { + // Arrange + UUID firstUuid = UUID.randomUUID(); + UUID secondUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Balanced", 6, 0.2); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse first = buildResponse(firstUuid, "Balanced", 6); + OASModelPortfolioResponse second = buildResponse(secondUuid, "Balanced", 6); + PaginatedOASModelPortfolioResponseList page = + new PaginatedOASModelPortfolioResponseList() + .count(2) + .results(List.of(first, second)); + when(financialAdviceApi.listModelPortfolio( + isNull(), isNull(), isNull(), eq(1), eq("Balanced"), + isNull(), isNull(), isNull(), eq(6), isNull())) + .thenReturn(Mono.just(page)); + + OASModelPortfolioResponse patched = buildResponse(firstUuid, "Balanced", 6); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(firstUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> assertThat(r.getUuid()).isEqualTo(firstUuid)) + .verifyComplete(); + + verify(customIntegrationApiService, never()).createModelPortfolioRequestCreation( + any(), any(), any(), any(), any()); + } + + /** + * Verifies that when the list API call itself fails with a reactive error, + * the error is propagated to the subscriber. + */ + @Test + @DisplayName("should propagate error when listModelPortfolio API call fails") + void listExisting_apiError_propagatesError() { + // Arrange + ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + when(financialAdviceApi.listModelPortfolio( + isNull(), isNull(), isNull(), eq(1), eq("Balanced"), + isNull(), isNull(), isNull(), eq(5), isNull())) + .thenReturn(Mono.error(new RuntimeException("list API failed"))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(RuntimeException.class) + .verify(); + } + } + + // ========================================================================= + // createNewModelPortfolio – create path + // ========================================================================= + + /** + * Tests for the internal {@code createNewModelPortfolio} logic, exercised + * indirectly through {@code upsertModels} when no existing portfolio is found. + */ + @Nested + @DisplayName("createNewModelPortfolio") + class CreateNewModelPortfolioTests { + + /** + * Verifies that a successful create call returns the newly created response, + * which carries the server-assigned UUID. + */ + @Test + @DisplayName("should return created portfolio response on successful create") + void createNew_success_returnsCreatedResponse() { + // Arrange + UUID newUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Income", 2); + OASModelPortfolioResponse created = buildResponse(newUuid, "Income", 2); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> { + assertThat(r.getUuid()).isEqualTo(newUuid); + assertThat(r.getName()).isEqualTo("Income"); + assertThat(r.getRiskLevel()).isEqualTo(2); + }) + .verifyComplete(); + } + + /** + * Verifies that a {@link WebClientResponseException} thrown by the create API + * is propagated as a reactive error signal to the subscriber. + */ + @Test + @DisplayName("should propagate WebClientResponseException from create API") + void createNew_webClientResponseException_propagatesError() { + // Arrange + ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Income", 2); + WebClientResponseException ex = WebClientResponseException.create( + HttpStatus.BAD_REQUEST.value(), "Bad Request", null, null, null); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.error(ex)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(WebClientResponseException.class) + .verify(); + } + + /** + * Verifies that a generic (non-WebClient) exception from the create API is also + * propagated without wrapping. + */ + @Test + @DisplayName("should propagate generic exception from create API") + void createNew_genericException_propagatesError() { + // Arrange + ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + stubListReturnsEmpty("Income", 2); + when(customIntegrationApiService.createModelPortfolioRequestCreation( + isNull(), isNull(), isNull(), any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.error(new IllegalStateException("unexpected"))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(IllegalStateException.class) + .verify(); + } + } + + // ========================================================================= + // patchModelPortfolio – patch path + // ========================================================================= + + /** + * Tests for the internal {@code patchModelPortfolio} logic, exercised + * indirectly through {@code upsertModels} when an existing portfolio is found. + */ + @Nested + @DisplayName("patchModelPortfolio") + class PatchModelPortfolioTests { + + /** + * Verifies that a successful patch call returns the patched response and + * the create API is never called. + */ + @Test + @DisplayName("should return patched portfolio response on successful patch") + void patch_success_returnsPatchedResponse() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); + stubListReturnsOne("Dynamic", 9, existing); + OASModelPortfolioResponse patched = buildResponse(existingUuid, "Dynamic", 9); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(existingUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> { + assertThat(r.getUuid()).isEqualTo(existingUuid); + assertThat(r.getName()).isEqualTo("Dynamic"); + assertThat(r.getRiskLevel()).isEqualTo(9); + }) + .verifyComplete(); + + verify(customIntegrationApiService, never()).createModelPortfolioRequestCreation( + any(), any(), any(), any(), any()); + } + + /** + * Verifies that the correct existing UUID is used when calling the patch API. + */ + @Test + @DisplayName("should call patch API with the UUID from the existing portfolio") + void patch_usesCorrectUuidFromExistingPortfolio() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Stable", 4, 0.3); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Stable", 4); + stubListReturnsOne("Stable", 4, existing); + + ArgumentCaptor uuidCaptor = ArgumentCaptor.forClass(String.class); + OASModelPortfolioResponse patched = buildResponse(existingUuid, "Stable", 4); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + uuidCaptor.capture(), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .assertNext(r -> assertThat(r.getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + assertThat(uuidCaptor.getValue()).isEqualTo(existingUuid.toString()); + } + + /** + * Verifies that a {@link WebClientResponseException} thrown by the patch API + * is propagated as a reactive error signal to the subscriber. + */ + @Test + @DisplayName("should propagate WebClientResponseException from patch API") + void patch_webClientResponseException_propagatesError() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); + stubListReturnsOne("Dynamic", 9, existing); + WebClientResponseException ex = WebClientResponseException.create( + HttpStatus.NOT_FOUND.value(), "Not Found", null, null, null); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(existingUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.error(ex)); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(WebClientResponseException.class) + .verify(); + } + + /** + * Verifies that a generic (non-WebClient) exception from the patch API is also + * propagated without wrapping. + */ + @Test + @DisplayName("should propagate generic exception from patch API") + void patch_genericException_propagatesError() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); + InvestmentData data = InvestmentData.builder() + .modelPortfolios(List.of(template)) + .build(); + + OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); + stubListReturnsOne("Dynamic", 9, existing); + when(customIntegrationApiService.patchModelPortfolioRequestCreation( + eq(existingUuid.toString()), isNull(), isNull(), isNull(), + any(OASModelPortfolioRequestDataRequest.class), isNull())) + .thenReturn(Mono.error(new RuntimeException("patch failed"))); + + // Act & Assert + StepVerifier.create(service.upsertModels(data)) + .expectError(RuntimeException.class) + .verify(); + } + } + + // ========================================================================= + // Helper / Builder Methods + // ========================================================================= + + /** + * Builds a {@link ModelPortfolio} with a single allocation for use across tests. + * + * @param name portfolio name + * @param riskLevel portfolio risk level + * @param cashWeight portfolio cash weight + * @return a fully populated {@link ModelPortfolio} instance + */ + private ModelPortfolio buildModelPortfolio(String name, int riskLevel, double cashWeight) { + ModelAsset asset = new ModelAsset("US0378331005", "XNAS", "USD"); + Allocation allocation = new Allocation(asset, 1.0 - cashWeight); + return ModelPortfolio.builder() + .name(name) + .riskLevel(riskLevel) + .cashWeight(cashWeight) + .allocations(List.of(allocation)) + .build(); + } + + /** + * Builds an {@link OASModelPortfolioResponse} with the given UUID, name and risk level. + * + * @param uuid server-assigned UUID + * @param name portfolio name + * @param riskLevel portfolio risk level + * @return a populated {@link OASModelPortfolioResponse} + */ + private OASModelPortfolioResponse buildResponse(UUID uuid, String name, int riskLevel) { + OASModelPortfolioResponse response = new OASModelPortfolioResponse(uuid); + response.setName(name); + response.setRiskLevel(riskLevel); + return response; + } + + /** + * Stubs {@link FinancialAdviceApi#listModelPortfolio} to return an empty result page + * for the given name and risk level. + * + * @param name the portfolio name to match + * @param riskLevel the risk level to match + */ + private void stubListReturnsEmpty(String name, int riskLevel) { + PaginatedOASModelPortfolioResponseList emptyPage = + new PaginatedOASModelPortfolioResponseList() + .count(0) + .results(Collections.emptyList()); + when(financialAdviceApi.listModelPortfolio( + any(), any(), any(), eq(1), eq(name), + any(), any(), any(), eq(riskLevel), any())) + .thenReturn(Mono.just(emptyPage)); + } + + /** + * Stubs {@link FinancialAdviceApi#listModelPortfolio} to return a page with exactly + * one result for the given name and risk level. + * + * @param name the portfolio name to match + * @param riskLevel the risk level to match + * @param response the single result to return + */ + private void stubListReturnsOne(String name, int riskLevel, OASModelPortfolioResponse response) { + PaginatedOASModelPortfolioResponseList page = + new PaginatedOASModelPortfolioResponseList() + .count(1) + .results(List.of(response)); + when(financialAdviceApi.listModelPortfolio( + any(), any(), any(), eq(1), eq(name), + any(), any(), any(), eq(riskLevel), any())) + .thenReturn(Mono.just(page)); + } +} + diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java new file mode 100644 index 000000000..fc5b90e95 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioAllocationServiceTest.java @@ -0,0 +1,656 @@ +package com.backbase.stream.investment.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.v1.AllocationsApi; +import com.backbase.investment.api.service.v1.AssetUniverseApi; +import com.backbase.investment.api.service.v1.InvestmentApi; +import com.backbase.investment.api.service.v1.model.AssetModelPortfolio; +import com.backbase.investment.api.service.v1.model.Deposit; +import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; +import com.backbase.investment.api.service.v1.model.OASAllocationCreateRequest; +import com.backbase.investment.api.service.v1.model.OASAllocationPosition; +import com.backbase.investment.api.service.v1.model.OASOrder; +import com.backbase.investment.api.service.v1.model.OASPortfolioAllocation; +import com.backbase.investment.api.service.v1.model.OASPrice; +import com.backbase.investment.api.service.v1.model.PaginatedOASOrderList; +import com.backbase.investment.api.service.v1.model.PaginatedOASPortfolioAllocationList; +import com.backbase.investment.api.service.v1.model.PaginatedOASPriceList; +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.RelatedAssetSerializerWithAssetCategories; +import com.backbase.stream.investment.Allocation; +import com.backbase.stream.investment.Asset; +import com.backbase.stream.investment.InvestmentAssetData; +import com.backbase.stream.investment.ModelAsset; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests for {@link InvestmentPortfolioAllocationService}. + * + *

Tests are grouped by method under {@link Nested} classes. Each nested class covers a single + * public method, and each test covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom reduce boilerplate
  • + *
+ */ +@SuppressWarnings("removal") +@DisplayName("InvestmentPortfolioAllocationService") +class InvestmentPortfolioAllocationServiceTest { + + private AllocationsApi allocationsApi; + private AssetUniverseApi assetUniverseApi; + private InvestmentApi investmentApi; + private CustomIntegrationApiService customIntegrationApiService; + private InvestmentPortfolioAllocationService service; + + @BeforeEach + void setUp() { + allocationsApi = mock(AllocationsApi.class); + assetUniverseApi = mock(AssetUniverseApi.class); + investmentApi = mock(InvestmentApi.class); + customIntegrationApiService = mock(CustomIntegrationApiService.class); + service = new InvestmentPortfolioAllocationService( + allocationsApi, assetUniverseApi, investmentApi, customIntegrationApiService); + } + + // ========================================================================= + // removeAllocations + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioAllocationService#removeAllocations(PortfolioList)}. + * + *

Covers: + *

    + *
  • Existing allocations found → each allocation deleted by valuation date
  • + *
  • No existing allocations → delete never called
  • + *
  • API error listing allocations → error propagated
  • + *
+ */ + @Nested + @DisplayName("removeAllocations") + class RemoveAllocationsTests { + + @Test + @DisplayName("existing allocations found — deletes each allocation and completes") + void removeAllocations_existingAllocations_deletesEachAndCompletes() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + + LocalDate date1 = LocalDate.now().minusDays(2); + LocalDate date2 = LocalDate.now().minusDays(1); + + OASPortfolioAllocation alloc1 = mock(OASPortfolioAllocation.class); + when(alloc1.getValuationDate()).thenReturn(date1); + OASPortfolioAllocation alloc2 = mock(OASPortfolioAllocation.class); + when(alloc2.getValuationDate()).thenReturn(date2); + + PaginatedOASPortfolioAllocationList page = mock(PaginatedOASPortfolioAllocationList.class); + when(page.getResults()).thenReturn(List.of(alloc1, alloc2)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(page)); + when(allocationsApi.deletePortfolioAllocation(portfolioUuid.toString(), date1)) + .thenReturn(Mono.empty()); + when(allocationsApi.deletePortfolioAllocation(portfolioUuid.toString(), date2)) + .thenReturn(Mono.empty()); + + // Act & Assert + StepVerifier.create(service.removeAllocations(portfolio)) + .verifyComplete(); + + verify(allocationsApi).deletePortfolioAllocation(portfolioUuid.toString(), date1); + verify(allocationsApi).deletePortfolioAllocation(portfolioUuid.toString(), date2); + } + + @Test + @DisplayName("no existing allocations — delete is never called") + void removeAllocations_noAllocations_deleteNeverCalled() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + + PaginatedOASPortfolioAllocationList emptyPage = mock(PaginatedOASPortfolioAllocationList.class); + when(emptyPage.getResults()).thenReturn(List.of()); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(emptyPage)); + + // Act & Assert + StepVerifier.create(service.removeAllocations(portfolio)) + .verifyComplete(); + + verify(allocationsApi, never()).deletePortfolioAllocation(any(), any()); + } + + @Test + @DisplayName("API error listing allocations — error propagated to caller") + void removeAllocations_apiErrorListing_propagatesError() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("API unavailable"))); + + // Act & Assert + StepVerifier.create(service.removeAllocations(portfolio)) + .expectErrorMatches(e -> e instanceof RuntimeException + && "API unavailable".equals(e.getMessage())) + .verify(); + } + } + + // ========================================================================= + // createDepositAllocation + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioAllocationService#createDepositAllocation(Deposit)}. + * + *

The service filter keeps the allocation list when at least one allocation has empty + * positions. If filtered out (all non-empty), {@code switchIfEmpty} fires and a new + * cash-active allocation is created. + * + *

Covers: + *

    + *
  • At least one allocation has empty positions → filter passes, deposit returned
  • + *
  • All allocations have non-empty positions → filter fails, new allocation created
  • + *
  • No allocations returned → switchIfEmpty triggers, new allocation created
  • + *
  • createPortfolioAllocation fails → error swallowed, Mono completes empty
  • + *
  • listPortfolioAllocations fails → error swallowed, Mono completes empty
  • + *
  • deposit completedAt is null → today used as valuation date
  • + *
+ */ + @Nested + @DisplayName("createDepositAllocation") + class CreateDepositAllocationTests { + + @Test + @DisplayName("allocation with empty positions found — filter passes, returns deposit without upsert") + void createDepositAllocation_emptyPositionsFound_returnsDepositWithoutUpsert() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate completedAt = LocalDate.now().minusDays(1); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 5_000d); + + OASPortfolioAllocation allocWithNoPositions = mock(OASPortfolioAllocation.class); + doReturn(List.of()).when(allocWithNoPositions).getPositions(); + + PaginatedOASPortfolioAllocationList page = mock(PaginatedOASPortfolioAllocationList.class); + when(page.getResults()).thenReturn(List.of(allocWithNoPositions)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(completedAt.minusDays(4)), eq(completedAt.plusDays(5)))) + .thenReturn(Mono.just(page)); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .expectNextMatches(d -> d == deposit) + .verifyComplete(); + + verify(customIntegrationApiService, never()) + .createPortfolioAllocation(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("all allocations have non-empty positions — filter fails, creates cash-active allocation") + void createDepositAllocation_allPositionsNonEmpty_createsCashAllocationAndReturnsDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate completedAt = LocalDate.now().minusDays(1); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 5_000d); + + OASPortfolioAllocation allocWithPositions = mock(OASPortfolioAllocation.class); + doReturn(List.of(mock(OASAllocationPosition.class))).when(allocWithPositions).getPositions(); + + PaginatedOASPortfolioAllocationList page = mock(PaginatedOASPortfolioAllocationList.class); + when(page.getResults()).thenReturn(List.of(allocWithPositions)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(completedAt.minusDays(4)), eq(completedAt.plusDays(5)))) + .thenReturn(Mono.just(page)); + + OASPortfolioAllocation created = mock(OASPortfolioAllocation.class); + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .expectNextMatches(d -> d == deposit) + .verifyComplete(); + + verify(customIntegrationApiService) + .createPortfolioAllocation(eq(portfolioUuid.toString()), + any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("no allocations returned — switchIfEmpty triggers, creates cash-active allocation") + void createDepositAllocation_noAllocations_createsCashAllocationAndReturnsDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate completedAt = LocalDate.now().minusDays(1); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 3_000d); + + PaginatedOASPortfolioAllocationList emptyPage = mock(PaginatedOASPortfolioAllocationList.class); + when(emptyPage.getResults()).thenReturn(List.of()); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(completedAt.minusDays(4)), eq(completedAt.plusDays(5)))) + .thenReturn(Mono.just(emptyPage)); + + OASPortfolioAllocation created = mock(OASPortfolioAllocation.class); + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .expectNextMatches(d -> d == deposit) + .verifyComplete(); + + verify(customIntegrationApiService) + .createPortfolioAllocation(eq(portfolioUuid.toString()), + any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("createPortfolioAllocation fails — onErrorResume swallows error, Mono completes empty") + void createDepositAllocation_upsertFails_errorSwallowed_returnsEmpty() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate completedAt = LocalDate.now().minusDays(1); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 5_000d); + + OASPortfolioAllocation allocWithPositions = mock(OASPortfolioAllocation.class); + doReturn(List.of(mock(OASAllocationPosition.class))).when(allocWithPositions).getPositions(); + + PaginatedOASPortfolioAllocationList page = mock(PaginatedOASPortfolioAllocationList.class); + when(page.getResults()).thenReturn(List.of(allocWithPositions)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(completedAt.minusDays(4)), eq(completedAt.plusDays(5)))) + .thenReturn(Mono.just(page)); + + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("downstream failure"))); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .verifyComplete(); + } + + @Test + @DisplayName("listPortfolioAllocations fails — top-level onErrorResume swallows, Mono completes empty") + void createDepositAllocation_listFails_errorSwallowed_returnsEmpty() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate completedAt = LocalDate.now().minusDays(1); + Deposit deposit = buildDeposit(portfolioUuid, completedAt, 5_000d); + + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(completedAt.minusDays(4)), eq(completedAt.plusDays(5)))) + .thenReturn(Mono.error(new RuntimeException("API unavailable"))); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .verifyComplete(); + + verify(customIntegrationApiService, never()) + .createPortfolioAllocation(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("deposit completedAt is null — uses today as valuation date") + void createDepositAllocation_nullCompletedAt_usesTodayAsValuationDate() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + LocalDate today = LocalDate.now(); + + Deposit deposit = mock(Deposit.class); + when(deposit.getPortfolio()).thenReturn(portfolioUuid); + when(deposit.getCompletedAt()).thenReturn(null); + when(deposit.getAmount()).thenReturn(3_000d); + + OASPortfolioAllocation allocWithPositions = mock(OASPortfolioAllocation.class); + doReturn(List.of(mock(OASAllocationPosition.class))).when(allocWithPositions).getPositions(); + + PaginatedOASPortfolioAllocationList page = mock(PaginatedOASPortfolioAllocationList.class); + when(page.getResults()).thenReturn(List.of(allocWithPositions)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(10), isNull(), isNull(), + eq(today.minusDays(4)), eq(today.plusDays(5)))) + .thenReturn(Mono.just(page)); + + OASPortfolioAllocation created = mock(OASPortfolioAllocation.class); + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.createDepositAllocation(deposit)) + .expectNextMatches(d -> d == deposit) + .verifyComplete(); + } + } + + // ========================================================================= + // generateAllocations + // ========================================================================= + + /** + * Tests for + * {@link InvestmentPortfolioAllocationService#generateAllocations(PortfolioList, List, InvestmentAssetData)}. + * + *

Covers: + *

    + *
  • Any pipeline error → onErrorResume swallows and returns empty
  • + *
  • No existing allocations, prices available → creates allocations via orderPositions
  • + *
  • Existing allocations with valuation date = today → no pending days, returns empty list
  • + *
  • No matching portfolio product → falls back to default model from asset universe
  • + *
+ */ + @Nested + @DisplayName("generateAllocations") + class GenerateAllocationsTests { + + @Test + @DisplayName("any error in allocation pipeline — onErrorResume swallows and returns empty Mono") + void generateAllocations_pipelineError_returnsEmptyMono() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + when(portfolio.getProduct()).thenReturn(UUID.randomUUID()); + when(portfolio.getActivated()).thenReturn(OffsetDateTime.now().minusMonths(6)); + + PortfolioProduct nonMatchingProduct = mock(PortfolioProduct.class); + when(nonMatchingProduct.getUuid()).thenReturn(UUID.randomUUID()); + + when(assetUniverseApi.listAssetClosePrices(any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any())) + .thenReturn(Mono.error(new RuntimeException("price API unavailable"))); + + InvestmentAssetData assetData = InvestmentAssetData.builder() + .assets(List.of(buildAsset("ISIN1", "XNAS", "USD"), buildAsset("ISIN2", "XNAS", "EUR"))) + .build(); + + // Act & Assert + StepVerifier.create(service.generateAllocations(portfolio, List.of(nonMatchingProduct), assetData)) + .verifyComplete(); + } + + @Test + @DisplayName("no existing allocations, prices available — creates new allocations via orderPositions") + void generateAllocations_noExistingAllocations_createsNewAllocations() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID productUuid = UUID.randomUUID(); + UUID modelUuid = UUID.randomUUID(); + + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + when(portfolio.getProduct()).thenReturn(productUuid); + when(portfolio.getActivated()).thenReturn(OffsetDateTime.now().minusMonths(2)); + + PortfolioProduct portfolioProduct = buildPortfolioProductWithModel( + productUuid, modelUuid, + List.of(new Allocation(new ModelAsset("ISIN1", "XNAS", "USD"), 0.8)), 0.2); + + // Nearest weekday at or before now()-10, avoiding weekend flakiness. + LocalDate priceDay = Stream.iterate(LocalDate.now().minusDays(10), d -> d.minusDays(1)) + .filter(d -> d.getDayOfWeek().getValue() <= 5) + .findFirst().orElseThrow(); + OASPrice price = mock(OASPrice.class); + when(price.getAmount()).thenReturn(100.0); + when(price.getDatetime()).thenReturn(priceDay.atTime(0, 0).atOffset(ZoneOffset.UTC)); + + PaginatedOASPriceList priceList = mock(PaginatedOASPriceList.class); + when(priceList.getResults()).thenReturn(List.of(price)); + when(assetUniverseApi.listAssetClosePrices( + eq("USD"), any(), any(), isNull(), isNull(), isNull(), isNull(), + eq("ISIN1"), isNull(), eq("XNAS"), isNull(), isNull())) + .thenReturn(Mono.just(priceList)); + + PaginatedOASPortfolioAllocationList emptyAllocPage = mock(PaginatedOASPortfolioAllocationList.class); + when(emptyAllocPage.getResults()).thenReturn(List.of()); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(1), isNull(), isNull(), any(), any())) + .thenReturn(Mono.just(emptyAllocPage)); + + PaginatedOASOrderList emptyOrderList = mock(PaginatedOASOrderList.class); + when(emptyOrderList.getResults()).thenReturn(List.of()); + when(investmentApi.listOrders(isNull(), any(), isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), eq(portfolioUuid.toString()), isNull())) + .thenReturn(Mono.just(emptyOrderList)); + + when(investmentApi.createOrder(any(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(mock(OASOrder.class))); + + OASPortfolioAllocation createdAlloc = mock(OASPortfolioAllocation.class); + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(createdAlloc)); + + InvestmentAssetData assetData = InvestmentAssetData.builder() + .assets(List.of(buildAsset("ISIN1", "XNAS", "USD"))) + .build(); + + // Act & Assert — one allocation created per work day from priceDay to today + StepVerifier.create(service.generateAllocations(portfolio, List.of(portfolioProduct), assetData)) + .expectNextMatches(result -> !result.isEmpty()) + .verifyComplete(); + + verify(customIntegrationApiService, atLeastOnce()) + .createPortfolioAllocation(eq(portfolioUuid.toString()), any(), isNull(), isNull(), isNull()); + } + + @Test + @DisplayName("existing allocations with valuation date = today — no pending days, returns empty list") + void generateAllocations_lastValuationIsToday_noPendingDays_returnsEmptyList() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID productUuid = UUID.randomUUID(); + UUID modelUuid = UUID.randomUUID(); + + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + when(portfolio.getProduct()).thenReturn(productUuid); + when(portfolio.getActivated()).thenReturn(OffsetDateTime.now().minusMonths(2)); + + PortfolioProduct portfolioProduct = buildPortfolioProductWithModel( + productUuid, modelUuid, + List.of(new Allocation(new ModelAsset("ISIN1", "XNAS", "USD"), 0.8)), 0.2); + + PaginatedOASPriceList priceList = mock(PaginatedOASPriceList.class); + when(priceList.getResults()).thenReturn(List.of()); + when(assetUniverseApi.listAssetClosePrices( + eq("USD"), any(), any(), isNull(), isNull(), isNull(), isNull(), + eq("ISIN1"), isNull(), eq("XNAS"), isNull(), isNull())) + .thenReturn(Mono.just(priceList)); + + Asset asset = buildAsset("ISIN1", "XNAS", "USD"); + + // Valuation date = today: nextValuationDate = tomorrow, workDays(tomorrow, today) = [] → List.of() + OASAllocationPosition existingPosition = mock(OASAllocationPosition.class); + when(existingPosition.getAsset()).thenReturn(asset.getUuid()); + when(existingPosition.getShares()).thenReturn(10.0); + when(existingPosition.getPrice()).thenReturn(100.0); + + OASPortfolioAllocation existingAlloc = mock(OASPortfolioAllocation.class); + when(existingAlloc.getValuationDate()).thenReturn(LocalDate.now()); + doReturn(List.of(existingPosition)).when(existingAlloc).getPositions(); + when(existingAlloc.getInvested()).thenReturn(10_000.0); + when(existingAlloc.getCashActive()).thenReturn(0.0); + when(existingAlloc.getTradeTotal()).thenReturn(1_000.0); + + PaginatedOASPortfolioAllocationList allocPage = mock(PaginatedOASPortfolioAllocationList.class); + when(allocPage.getResults()).thenReturn(List.of(existingAlloc)); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(1), isNull(), isNull(), any(), any())) + .thenReturn(Mono.just(allocPage)); + + InvestmentAssetData assetData = InvestmentAssetData.builder() + .assets(List.of(asset)) + .build(); + + // Act & Assert + StepVerifier.create(service.generateAllocations(portfolio, List.of(portfolioProduct), assetData)) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(customIntegrationApiService, never()) + .createPortfolioAllocation(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("no matching portfolio product — falls back to default model from first two assets") + void generateAllocations_noMatchingPortfolioProduct_fallsBackToDefaultModel() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioList portfolio = buildPortfolioList(portfolioUuid); + when(portfolio.getProduct()).thenReturn(UUID.randomUUID()); + when(portfolio.getActivated()).thenReturn(OffsetDateTime.now().minusMonths(2)); + + PortfolioProduct nonMatchingProduct = mock(PortfolioProduct.class); + when(nonMatchingProduct.getUuid()).thenReturn(UUID.randomUUID()); + + Asset asset1 = buildAsset("ISIN1", "XNAS", "USD"); + Asset asset2 = buildAsset("ISIN2", "XNAS", "EUR"); + + // Nearest weekday at or before now()-5, avoiding weekend flakiness. + LocalDate priceDay = Stream.iterate(LocalDate.now().minusDays(5), d -> d.minusDays(1)) + .filter(d -> d.getDayOfWeek().getValue() <= 5) + .findFirst().orElseThrow(); + + OASPrice price1 = mock(OASPrice.class); + when(price1.getAmount()).thenReturn(50.0); + when(price1.getDatetime()).thenReturn(priceDay.atTime(0, 0).atOffset(ZoneOffset.UTC)); + PaginatedOASPriceList priceList1 = mock(PaginatedOASPriceList.class); + when(priceList1.getResults()).thenReturn(List.of(price1)); + + OASPrice price2 = mock(OASPrice.class); + when(price2.getAmount()).thenReturn(75.0); + when(price2.getDatetime()).thenReturn(priceDay.atTime(0, 0).atOffset(ZoneOffset.UTC)); + PaginatedOASPriceList priceList2 = mock(PaginatedOASPriceList.class); + when(priceList2.getResults()).thenReturn(List.of(price2)); + + when(assetUniverseApi.listAssetClosePrices( + eq("USD"), any(), any(), isNull(), isNull(), isNull(), isNull(), + eq("ISIN1"), isNull(), eq("XNAS"), isNull(), isNull())) + .thenReturn(Mono.just(priceList1)); + when(assetUniverseApi.listAssetClosePrices( + eq("EUR"), any(), any(), isNull(), isNull(), isNull(), isNull(), + eq("ISIN2"), isNull(), eq("XNAS"), isNull(), isNull())) + .thenReturn(Mono.just(priceList2)); + + PaginatedOASPortfolioAllocationList emptyAllocPage = mock(PaginatedOASPortfolioAllocationList.class); + when(emptyAllocPage.getResults()).thenReturn(List.of()); + when(allocationsApi.listPortfolioAllocations( + eq(portfolioUuid.toString()), isNull(), isNull(), eq(1), isNull(), isNull(), any(), any())) + .thenReturn(Mono.just(emptyAllocPage)); + + PaginatedOASOrderList emptyOrderList = mock(PaginatedOASOrderList.class); + when(emptyOrderList.getResults()).thenReturn(List.of()); + when(investmentApi.listOrders(isNull(), any(), isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), isNull(), isNull(), isNull(), eq(portfolioUuid.toString()), isNull())) + .thenReturn(Mono.just(emptyOrderList)); + when(investmentApi.createOrder(any(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(mock(OASOrder.class))); + + OASPortfolioAllocation createdAlloc = mock(OASPortfolioAllocation.class); + when(customIntegrationApiService.createPortfolioAllocation( + eq(portfolioUuid.toString()), any(OASAllocationCreateRequest.class), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(createdAlloc)); + + InvestmentAssetData assetData = InvestmentAssetData.builder() + .assets(List.of(asset1, asset2)) + .build(); + + // Act & Assert + StepVerifier.create(service.generateAllocations(portfolio, List.of(nonMatchingProduct), assetData)) + .expectNextMatches(result -> !result.isEmpty()) + .verifyComplete(); + + verify(customIntegrationApiService, atLeastOnce()) + .createPortfolioAllocation(eq(portfolioUuid.toString()), any(), isNull(), isNull(), isNull()); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private PortfolioList buildPortfolioList(UUID portfolioUuid) { + PortfolioList portfolio = mock(PortfolioList.class); + when(portfolio.getUuid()).thenReturn(portfolioUuid); + return portfolio; + } + + private Deposit buildDeposit(UUID portfolioUuid, LocalDate completedAt, double amount) { + Deposit deposit = mock(Deposit.class); + when(deposit.getPortfolio()).thenReturn(portfolioUuid); + when(deposit.getCompletedAt()).thenReturn(completedAt.atTime(0, 0).atOffset(ZoneOffset.UTC)); + when(deposit.getAmount()).thenReturn(amount); + return deposit; + } + + private Asset buildAsset(String isin, String market, String currency) { + Asset asset = new Asset(); + asset.setUuid(UUID.randomUUID()); + asset.setIsin(isin); + asset.setMarket(market); + asset.setCurrency(currency); + return asset; + } + + private PortfolioProduct buildPortfolioProductWithModel(UUID productUuid, UUID modelUuid, + List allocations, double cashWeight) { + + List apiAllocations = allocations.stream().map(a -> { + RelatedAssetSerializerWithAssetCategories apiAsset = new RelatedAssetSerializerWithAssetCategories(); + apiAsset.setIsin(a.asset().getIsin()); + apiAsset.setMarket(a.asset().getMarket()); + apiAsset.setCurrency(a.asset().getCurrency()); + return new AssetModelPortfolio().asset(apiAsset).weight(a.weight()); + }).toList(); + + InvestorModelPortfolio investorModel = new InvestorModelPortfolio( + modelUuid, null, cashWeight, null, apiAllocations, null); + + PortfolioProduct product = mock(PortfolioProduct.class); + when(product.getUuid()).thenReturn(productUuid); + when(product.getModelPortfolio()).thenReturn(investorModel); + return product; + } +} + 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 bbe891209..76d7521dc 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.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -7,19 +8,31 @@ import com.backbase.investment.api.service.v1.PaymentsApi; import com.backbase.investment.api.service.v1.PortfolioApi; 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.PaginatedDepositList; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioListList; +import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioTradingAccountList; 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; import com.backbase.investment.api.service.v1.model.PortfolioTradingAccountRequest; +import com.backbase.investment.api.service.v1.model.ProductTypeEnum; import com.backbase.investment.api.service.v1.model.StatusA3dEnum; import com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties; +import com.backbase.stream.investment.InvestmentArrangement; +import com.backbase.stream.investment.InvestmentData; +import com.backbase.stream.investment.ModelPortfolio; import com.backbase.stream.investment.model.InvestmentPortfolioTradingAccount; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.http.HttpHeaders; @@ -28,6 +41,22 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +/** + * Unit tests for {@link InvestmentPortfolioService}. + * + *

Tests are grouped by method under {@link Nested} classes to improve readability + * and navigation. Each nested class covers a single public or package-visible method, + * and each test method covers a specific branch or edge case. + * + *

Conventions: + *

    + *
  • All dependencies are mocked via Mockito
  • + *
  • Reactive assertions use {@link StepVerifier}
  • + *
  • Arrange-Act-Assert structure is followed throughout
  • + *
  • Helper methods at the bottom of the class reduce boilerplate
  • + *
+ */ +@DisplayName("InvestmentPortfolioService") class InvestmentPortfolioServiceTest { private InvestmentProductsApi productsApi; @@ -49,305 +78,1053 @@ void setUp() { productsApi, portfolioApi, paymentsApi, portfolioTradingAccountsApi, config); } - // ----------------------------------------------------------------------- - // upsertPortfolioTradingAccount — patch path - // ----------------------------------------------------------------------- - - @Test - void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { - UUID existingUuid = UUID.randomUUID(); - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); - when(existing.getUuid()).thenReturn(existingUuid); - when(existing.getExternalAccountId()).thenReturn("EXT-001"); - - PortfolioTradingAccount patched = Mockito.mock(PortfolioTradingAccount.class); - when(patched.getUuid()).thenReturn(existingUuid); - when(patched.getExternalAccountId()).thenReturn("EXT-001"); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-001") - .accountId("ACC-001") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() - .results(List.of(existing)); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-001"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(accountList)); - - when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( - eq(existingUuid.toString()), any())) - .thenReturn(Mono.just(patched)); - - StepVerifier.create(service.upsertPortfolioTradingAccount(request)) - .expectNextMatches(a -> a.getUuid().equals(existingUuid)) - .verifyComplete(); - } - - @Test - void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { - UUID newUuid = UUID.randomUUID(); - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); - when(created.getUuid()).thenReturn(newUuid); - when(created.getExternalAccountId()).thenReturn("EXT-002"); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-002") - .accountId("ACC-002") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-002"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); - - when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) - .thenReturn(Mono.just(created)); - - StepVerifier.create(service.upsertPortfolioTradingAccount(request)) - .expectNextMatches(a -> a.getUuid().equals(newUuid)) - .verifyComplete(); + // ========================================================================= + // upsertPortfolioTradingAccount + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertPortfolioTradingAccount(PortfolioTradingAccountRequest)}. + * + *

Covers: + *

    + *
  • Existing account found → patch succeeds
  • + *
  • No existing account → create new
  • + *
  • Patch fails with {@link WebClientResponseException} → falls back to existing
  • + *
  • Patch fails with non-WebClient exception → error propagated
  • + *
  • Multiple existing accounts → {@link IllegalStateException}
  • + *
+ */ + @Nested + @DisplayName("upsertPortfolioTradingAccount") + class UpsertPortfolioTradingAccountTests { + + @Test + @DisplayName("existing account found — patches and returns updated account") + void upsertPortfolioTradingAccount_existingAccount_patchesAndReturns() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-001"); + + PortfolioTradingAccount patched = Mockito.mock(PortfolioTradingAccount.class); + when(patched.getUuid()).thenReturn(existingUuid); + when(patched.getExternalAccountId()).thenReturn("EXT-001"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-001") + .externalAccountId("EXT-001") + .isDefault(true) + .isInternal(false); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-001"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + existingUuid.toString(), request)) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> existingUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi).patchPortfolioTradingAccount( + existingUuid.toString(), request); + verify(portfolioTradingAccountsApi, never()).createPortfolioTradingAccount(any()); + } + + @Test + @DisplayName("no existing account found — creates and returns new account") + void upsertPortfolioTradingAccount_noExistingAccount_createsNew() { + // Arrange + UUID newUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(newUuid); + when(created.getExternalAccountId()).thenReturn("EXT-002"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-002") + .externalAccountId("EXT-002") + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-002"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> newUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi).createPortfolioTradingAccount(request); + verify(portfolioTradingAccountsApi, never()).patchPortfolioTradingAccount(any(), any()); + } + + @Test + @DisplayName("patch fails with WebClientResponseException — falls back to existing account") + void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackToExisting() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-003"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-003") + .externalAccountId("EXT-003") + .isDefault(false) + .isInternal(true); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-003"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + existingUuid.toString(), request)) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.UNPROCESSABLE_ENTITY.value(), "Unprocessable Entity", + HttpHeaders.EMPTY, "patch error".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) + .thenReturn(Mono.error(new RuntimeException("should not be called"))); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectNextMatches(acc -> existingUuid.equals(acc.getUuid())) + .verifyComplete(); + + verify(portfolioTradingAccountsApi, never()).createPortfolioTradingAccount(any()); + } + + @Test + @DisplayName("patch fails with non-WebClient exception — propagates error") + void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagatesError() { + // Arrange + UUID existingUuid = UUID.randomUUID(); + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); + when(existing.getUuid()).thenReturn(existingUuid); + when(existing.getExternalAccountId()).thenReturn("EXT-004"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-004") + .externalAccountId("EXT-004") + .isDefault(false) + .isInternal(false); + + PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() + .results(List.of(existing)); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-004"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(accountList)); + + when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( + existingUuid.toString(), request)) + .thenReturn(Mono.error(new RuntimeException("Unexpected DB error"))); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof RuntimeException + && e.getMessage().equals("Unexpected DB error")) + .verify(); + } + + @Test + @DisplayName("multiple existing accounts found — returns IllegalStateException") + void upsertPortfolioTradingAccount_multipleExistingAccounts_returnsError() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccount acc1 = Mockito.mock(PortfolioTradingAccount.class); + when(acc1.getUuid()).thenReturn(UUID.randomUUID()); + PortfolioTradingAccount acc2 = Mockito.mock(PortfolioTradingAccount.class); + when(acc2.getUuid()).thenReturn(UUID.randomUUID()); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-005") + .externalAccountId("EXT-005") + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("EXT-005"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList() + .results(List.of(acc1, acc2)))); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) + .thenReturn(Mono.error(new RuntimeException("should not be called"))); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof IllegalStateException) + .verify(); + } } - @Test - void upsertPortfolioTradingAccount_patchFails_withWebClientException_fallsBackToExisting() { - UUID existingUuid = UUID.randomUUID(); - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); - when(existing.getUuid()).thenReturn(existingUuid); - when(existing.getExternalAccountId()).thenReturn("EXT-003"); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-003") - .accountId("ACC-003") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() - .results(List.of(existing)); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-003"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(accountList)); - - when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( - (existingUuid.toString()), (request))) - .thenReturn(Mono.error(WebClientResponseException.create( - HttpStatus.BAD_REQUEST.value(), "Bad Request", - HttpHeaders.EMPTY, null, StandardCharsets.UTF_8))); - - when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) - .thenReturn(Mono.just(existing)); - - StepVerifier.create(service.upsertPortfolioTradingAccount(request)) - .expectNextMatches(a -> a.getUuid().equals(existingUuid)) - .verifyComplete(); + // ========================================================================= + // upsertPortfolioTradingAccounts (batch) + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertPortfolioTradingAccounts(List)}. + * + *

Covers: + *

    + *
  • Null input → empty list
  • + *
  • Empty input → empty list
  • + *
  • Null portfolioExternalId on account → account is skipped
  • + *
  • Single failure in batch → remaining accounts processed
  • + *
  • All accounts fail → empty list returned
  • + *
+ */ + @Nested + @DisplayName("upsertPortfolioTradingAccounts") + class UpsertPortfolioTradingAccountsTests { + + @Test + @DisplayName("null input — returns empty list without calling API") + void upsertPortfolioTradingAccounts_nullInput_returnsEmptyList() { + StepVerifier.create(service.upsertPortfolioTradingAccounts(null)) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verifyNoInteractions(portfolioTradingAccountsApi); + } + + @Test + @DisplayName("empty input — returns empty list without calling API") + void upsertPortfolioTradingAccounts_emptyInput_returnsEmptyList() { + StepVerifier.create(service.upsertPortfolioTradingAccounts(List.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verifyNoInteractions(portfolioTradingAccountsApi); + } + + @Test + @DisplayName("null portfolioExternalId on account — account is silently skipped") + void upsertPortfolioTradingAccounts_nullPortfolioExternalId_skipsAccount() { + // Arrange + InvestmentPortfolioTradingAccount account = InvestmentPortfolioTradingAccount.builder() + .accountExternalId("EXT-NULL-PORTFOLIO") + .portfolioExternalId(null) + .accountId("ACC-NULL") + .isDefault(false) + .isInternal(false) + .build(); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccounts(List.of(account))) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verify(portfolioApi, never()).listPortfolios( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("single account fails in batch — remaining accounts still processed") + void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { + // Arrange + UUID portfolioUuid1 = UUID.randomUUID(); + UUID portfolioUuid2 = UUID.randomUUID(); + String externalId1 = "PORTFOLIO-EXT-001"; + String externalId2 = "PORTFOLIO-EXT-002"; + + mockPortfolioFound(externalId1, portfolioUuid1); + mockPortfolioFound(externalId2, portfolioUuid2); + + // Account 1: list returns empty → create fails + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("ACC-FAIL-001"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount( + argThat(r -> r != null && "ACC-FAIL-001".equals(r.getExternalAccountId())))) + .thenReturn(Mono.error(new RuntimeException("API failure for account 1"))); + + // Account 2: list returns empty → create succeeds + UUID createdUuid = UUID.randomUUID(); + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(createdUuid); + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("ACC-OK-002"), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); + when(portfolioTradingAccountsApi.createPortfolioTradingAccount( + argThat(r -> r != null && "ACC-OK-002".equals(r.getExternalAccountId())))) + .thenReturn(Mono.just(created)); + + List input = List.of( + InvestmentPortfolioTradingAccount.builder() + .accountExternalId("ACC-FAIL-001") + .portfolioExternalId(externalId1) + .accountId("ACC-FAIL-001") + .isDefault(false) + .isInternal(false) + .build(), + InvestmentPortfolioTradingAccount.builder() + .accountExternalId("ACC-OK-002") + .portfolioExternalId(externalId2) + .accountId("ACC-OK-002") + .isDefault(false) + .isInternal(false) + .build() + ); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) + .expectNextMatches(list -> list.size() == 1 + && createdUuid.equals(list.get(0).getUuid())) + .verifyComplete(); + } + + @Test + @DisplayName("all accounts fail — returns empty list without throwing") + void upsertPortfolioTradingAccounts_allFail_returnsEmptyList() { + // Arrange + String externalId = "PORTFOLIO-EXT-ALL-FAIL"; + UUID portfolioUuid = UUID.randomUUID(); + + mockPortfolioFound(externalId, portfolioUuid); + + when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( + eq(1), isNull(), isNull(), eq("ACC-ALL-FAIL"), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("API failure"))); + + List input = List.of( + InvestmentPortfolioTradingAccount.builder() + .accountExternalId("ACC-ALL-FAIL") + .portfolioExternalId(externalId) + .accountId("ACC-ALL-FAIL") + .isDefault(false) + .isInternal(false) + .build() + ); + + // Act & Assert + StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + } } - @Test - void upsertPortfolioTradingAccount_patchFails_withNonWebClientException_propagatesError() { - UUID existingUuid = UUID.randomUUID(); - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccount existing = Mockito.mock(PortfolioTradingAccount.class); - when(existing.getUuid()).thenReturn(existingUuid); - when(existing.getExternalAccountId()).thenReturn("EXT-004"); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-004") - .accountId("ACC-004") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - PaginatedPortfolioTradingAccountList accountList = new PaginatedPortfolioTradingAccountList() - .results(List.of(existing)); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-004"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(accountList)); - - when(portfolioTradingAccountsApi.patchPortfolioTradingAccount( - (existingUuid.toString()), (request))) - .thenReturn(Mono.error(new RuntimeException("Unexpected error"))); - - StepVerifier.create(service.upsertPortfolioTradingAccount(request)) - .expectErrorMatches(e -> e instanceof RuntimeException - && e.getMessage().equals("Unexpected error")) - .verify(); + // ========================================================================= + // createPortfolioTradingAccount + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#createPortfolioTradingAccount(PortfolioTradingAccountRequest)}. + * + *

Covers: + *

    + *
  • API returns successfully → account emitted
  • + *
  • API throws → error propagated
  • + *
+ */ + @Nested + @DisplayName("createPortfolioTradingAccount") + class CreatePortfolioTradingAccountTests { + + @Test + @DisplayName("API succeeds — returns created account") + void createPortfolioTradingAccount_success_returnsCreatedAccount() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID newUuid = UUID.randomUUID(); + + PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); + when(created.getUuid()).thenReturn(newUuid); + when(created.getExternalAccountId()).thenReturn("EXT-NEW"); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-NEW") + .externalAccountId("EXT-NEW") + .isDefault(true) + .isInternal(false); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.createPortfolioTradingAccount(request)) + .expectNextMatches(acc -> newUuid.equals(acc.getUuid()) + && "EXT-NEW".equals(acc.getExternalAccountId())) + .verifyComplete(); + } + + @Test + @DisplayName("API fails — propagates error to caller") + void createPortfolioTradingAccount_apiFails_propagatesError() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + + PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() + .portfolio(portfolioUuid) + .accountId("ACC-ERR") + .externalAccountId("EXT-ERR") + .isDefault(false) + .isInternal(false); + + when(portfolioTradingAccountsApi.createPortfolioTradingAccount(request)) + .thenReturn(Mono.error(new RuntimeException("downstream failure"))); + + // Act & Assert + StepVerifier.create(service.createPortfolioTradingAccount(request)) + .expectErrorMatches(e -> e instanceof RuntimeException + && "downstream failure".equals(e.getMessage())) + .verify(); + } } - @Test - void upsertPortfolioTradingAccount_multipleExistingAccounts_returnsError() { - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccount acc1 = Mockito.mock(PortfolioTradingAccount.class); - when(acc1.getUuid()).thenReturn(UUID.randomUUID()); - PortfolioTradingAccount acc2 = Mockito.mock(PortfolioTradingAccount.class); - when(acc2.getUuid()).thenReturn(UUID.randomUUID()); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-DUP") - .accountId("ACC-DUP") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-DUP"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList() - .results(List.of(acc1, acc2)))); - - // Defensive stub — prevents NPE if switchIfEmpty is accidentally reached - when(portfolioTradingAccountsApi.createPortfolioTradingAccount(any())) - .thenReturn(Mono.error(new IllegalStateException("should not be called"))); - - StepVerifier.create(service.upsertPortfolioTradingAccount(request)) - .expectErrorMatches(e -> e instanceof IllegalStateException - && e.getMessage().contains("Found 2 portfolio trading accounts")) - .verify(); + // ========================================================================= + // upsertInvestmentPortfolios + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertInvestmentPortfolios(InvestmentArrangement, Map)}. + * + *

Covers: + *

    + *
  • Existing portfolio found → patch succeeds
  • + *
  • No existing portfolio → create new
  • + *
  • Patch fails with {@link WebClientResponseException} → falls back to existing
  • + *
  • Multiple portfolios returned → {@link IllegalStateException}
  • + *
  • Null arrangement → {@link NullPointerException}
  • + *
+ */ + @Nested + @DisplayName("upsertInvestmentPortfolios") + class UpsertInvestmentPortfoliosTests { + + @Test + @DisplayName("existing portfolio found — patches and returns updated portfolio") + void upsertInvestmentPortfolios_existingPortfolio_patchesAndReturns() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-PATCH"; + String leExternalId = "LE-001"; + UUID clientUuid = UUID.randomUUID(); + + InvestmentArrangement arrangement = buildArrangement(externalId, "Test Portfolio", productId, leExternalId); + PortfolioList existing = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); + PortfolioList patched = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); + PortfolioList fallbackCreated = buildPortfolioList(UUID.randomUUID(), 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.createPortfolio(any(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(fallbackCreated)); + when(portfolioApi.patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any())) + .thenReturn(Mono.just(patched)); + + Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, clientsByLeExternalId)) + .expectNextMatches(p -> portfolioUuid.equals(p.getUuid())) + .verifyComplete(); + + verify(portfolioApi).patchPortfolio(eq(portfolioUuid.toString()), isNull(), isNull(), isNull(), any()); + verify(portfolioApi, never()).createPortfolio(any(), any(), any(), any()); + } + + @Test + @DisplayName("no existing portfolio — creates and returns new portfolio") + void upsertInvestmentPortfolios_noExistingPortfolio_createsNew() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-NEW"; + String leExternalId = "LE-002"; + UUID clientUuid = UUID.randomUUID(); + + InvestmentArrangement arrangement = buildArrangement(externalId, "New Portfolio", productId, leExternalId); + + 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)); + + Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, clientsByLeExternalId)) + .expectNextMatches(p -> portfolioUuid.equals(p.getUuid())) + .verifyComplete(); + + verify(portfolioApi).createPortfolio(any(), isNull(), isNull(), isNull()); + verify(portfolioApi, never()).patchPortfolio(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("patch fails with WebClientResponseException — falls back to existing portfolio") + void upsertInvestmentPortfolios_patchFails_withWebClientException_fallsBackToExisting() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-PATCH-FAIL"; + String leExternalId = "LE-003"; + UUID clientUuid = UUID.randomUUID(); + + InvestmentArrangement arrangement = buildArrangement(externalId, "Patch Fail Portfolio", productId, leExternalId); + PortfolioList existing = 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.error(WebClientResponseException.create( + HttpStatus.UNPROCESSABLE_ENTITY.value(), "Unprocessable Entity", + HttpHeaders.EMPTY, "patch error".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))); + + Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, clientsByLeExternalId)) + .expectNextMatches(p -> portfolioUuid.equals(p.getUuid())) + .verifyComplete(); + + verify(portfolioApi, never()).createPortfolio(any(), any(), any(), any()); + } + + @Test + @DisplayName("multiple portfolios returned for same externalId — returns IllegalStateException") + void upsertInvestmentPortfolios_multipleExistingPortfolios_returnsError() { + // Arrange + UUID productId = UUID.randomUUID(); + String externalId = "PORTFOLIO-EXT-DUP"; + String leExternalId = "LE-DUP"; + + InvestmentArrangement arrangement = buildArrangement(externalId, "Dup Portfolio", productId, leExternalId); + + PortfolioList p1 = buildPortfolioList(UUID.randomUUID(), externalId, OffsetDateTime.now().minusMonths(6)); + PortfolioList p2 = buildPortfolioList(UUID.randomUUID(), externalId, OffsetDateTime.now().minusMonths(6)); + + PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); + when(paginatedList.getResults()).thenReturn(List.of(p1, p2)); + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(paginatedList)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, Map.of())) + .expectErrorMatches(e -> e instanceof IllegalStateException) + .verify(); + } + + @Test + @DisplayName("null arrangement — throws NullPointerException immediately") + void upsertInvestmentPortfolios_nullArrangement_throwsNullPointerException() { + assertThrows(NullPointerException.class, + () -> service.upsertInvestmentPortfolios(null, Map.of())); + } } - // ----------------------------------------------------------------------- - // upsertPortfolioTradingAccounts — batch resilience - // ----------------------------------------------------------------------- - - @Test - void upsertPortfolioTradingAccounts_nullInput_returnsEmptyList() { - StepVerifier.create(service.upsertPortfolioTradingAccounts(null)) - .expectNext(List.of()) - .verifyComplete(); + // ========================================================================= + // upsertPortfolios (batch) + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertPortfolios(List, Map)}. + * + *

Covers: + *

    + *
  • Multiple arrangements → all portfolios returned
  • + *
  • Empty arrangements list → empty list returned
  • + *
+ */ + @Nested + @DisplayName("upsertPortfolios") + class UpsertPortfoliosTests { + + @Test + @DisplayName("multiple arrangements — all portfolios created and returned") + void upsertPortfolios_multipleArrangements_returnsAllPortfolios() { + // Arrange + UUID portfolioUuid1 = UUID.randomUUID(); + UUID portfolioUuid2 = UUID.randomUUID(); + UUID productId = UUID.randomUUID(); + String externalId1 = "EXT-BATCH-001"; + String externalId2 = "EXT-BATCH-002"; + String leExternalId = "LE-BATCH"; + UUID clientUuid = UUID.randomUUID(); + + InvestmentArrangement arrangement1 = buildArrangement(externalId1, "Portfolio 1", productId, leExternalId); + InvestmentArrangement arrangement2 = buildArrangement(externalId2, "Portfolio 2", productId, leExternalId); + + // Stub listPortfolios per externalId — both return empty (no existing portfolio) + PaginatedPortfolioListList emptyList1 = Mockito.mock(PaginatedPortfolioListList.class); + when(emptyList1.getResults()).thenReturn(List.of()); + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId1), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(emptyList1)); + + PaginatedPortfolioListList emptyList2 = Mockito.mock(PaginatedPortfolioListList.class); + when(emptyList2.getResults()).thenReturn(List.of()); + when(portfolioApi.listPortfolios(isNull(), isNull(), isNull(), + isNull(), eq(externalId2), isNull(), isNull(), eq(1), + isNull(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(emptyList2)); + + // createPortfolio is called once per arrangement — chain returns so each call gets the right result + PortfolioList created1 = buildPortfolioList(portfolioUuid1, externalId1, OffsetDateTime.now().minusMonths(6)); + PortfolioList created2 = buildPortfolioList(portfolioUuid2, externalId2, OffsetDateTime.now().minusMonths(6)); + when(portfolioApi.createPortfolio(any(), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(created1)) + .thenReturn(Mono.just(created2)); + + Map> clientsByLeExternalId = Map.of(leExternalId, List.of(clientUuid)); + + // Act & Assert + StepVerifier.create(service.upsertPortfolios(List.of(arrangement1, arrangement2), clientsByLeExternalId)) + .expectNextMatches(list -> list.size() == 2) + .verifyComplete(); + } + + @Test + @DisplayName("empty arrangements list — returns empty list without calling API") + void upsertPortfolios_emptyArrangements_returnsEmptyList() { + StepVerifier.create(service.upsertPortfolios(List.of(), Map.of())) + .expectNextMatches(List::isEmpty) + .verifyComplete(); + + verifyNoInteractions(portfolioApi); + } } - @Test - void upsertPortfolioTradingAccounts_emptyInput_returnsEmptyList() { - StepVerifier.create(service.upsertPortfolioTradingAccounts(List.of())) - .expectNext(List.of()) - .verifyComplete(); + // ========================================================================= + // upsertDeposits + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertDeposits(PortfolioList)}. + * + *

Covers: + *

    + *
  • No existing deposits → creates default deposit of 10,000
  • + *
  • Existing deposits sum less than default → creates top-up deposit
  • + *
  • Existing deposits sum equals or exceeds default → returns last deposit without creating
  • + *
  • Null deposit results → creates default deposit
  • + *
  • API error listing deposits → returns fallback deposit
  • + *
+ */ + @Nested + @DisplayName("upsertDeposits") + class UpsertDepositsTests { + + @Test + @DisplayName("no existing deposits — creates default deposit of 10,000") + void upsertDeposits_noExistingDeposits_createsDefaultDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-DEP-001", + OffsetDateTime.now().minusMonths(6)); + + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedDepositList().results(List.of()))); + + Deposit created = Mockito.mock(Deposit.class); + when(created.getAmount()).thenReturn(10_000d); + when(paymentsApi.createDeposit(any(DepositRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertDeposits(portfolio)) + .expectNextMatches(d -> Double.valueOf(10_000d).equals(d.getAmount())) + .verifyComplete(); + + verify(paymentsApi).createDeposit(any(DepositRequest.class)); + } + + @Test + @DisplayName("existing deposits sum less than default — creates top-up deposit for remaining amount") + void upsertDeposits_existingDepositsLessThanDefault_topsUpRemainingAmount() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-DEP-002", + OffsetDateTime.now().minusMonths(6)); + + Deposit existingDeposit = Mockito.mock(Deposit.class); + when(existingDeposit.getAmount()).thenReturn(4_000d); + + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedDepositList().results(List.of(existingDeposit)))); + + Deposit topUpDeposit = Mockito.mock(Deposit.class); + when(topUpDeposit.getAmount()).thenReturn(6_000d); + when(paymentsApi.createDeposit(any(DepositRequest.class))) + .thenReturn(Mono.just(topUpDeposit)); + + // Act & Assert + StepVerifier.create(service.upsertDeposits(portfolio)) + .expectNextMatches(d -> Double.valueOf(6_000d).equals(d.getAmount())) + .verifyComplete(); + + verify(paymentsApi).createDeposit(argThat(req -> req.getAmount() == 6_000d)); + } + + @Test + @DisplayName("existing deposits equal to default — returns last deposit without creating a new one") + void upsertDeposits_existingDepositsEqualToDefault_doesNotCreateNewDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-DEP-003", + OffsetDateTime.now().minusMonths(6)); + + Deposit existingDeposit = Mockito.mock(Deposit.class); + when(existingDeposit.getAmount()).thenReturn(10_000d); + + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedDepositList().results(List.of(existingDeposit)))); + + Deposit fallbackDeposit = Mockito.mock(Deposit.class); + when(paymentsApi.createDeposit(any())).thenReturn(Mono.just(fallbackDeposit)); + // Act & Assert + StepVerifier.create(service.upsertDeposits(portfolio)) + .expectNextMatches(d -> Double.valueOf(10_000d).equals(d.getAmount())) + .verifyComplete(); + + verify(paymentsApi, never()).createDeposit(any()); + } + + @Test + @DisplayName("null deposit results in paginated response — creates default deposit") + void upsertDeposits_nullDepositResultList_createsDefaultDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-DEP-NULL", + OffsetDateTime.now().minusMonths(6)); + + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.just(new PaginatedDepositList().results(null))); + + Deposit created = Mockito.mock(Deposit.class); + when(created.getAmount()).thenReturn(10_000d); + when(paymentsApi.createDeposit(any(DepositRequest.class))) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertDeposits(portfolio)) + .expectNextMatches(d -> Double.valueOf(10_000d).equals(d.getAmount())) + .verifyComplete(); + + verify(paymentsApi).createDeposit(any(DepositRequest.class)); + } + + @Test + @DisplayName("API error listing deposits — returns fallback deposit with portfolio UUID and default amount") + void upsertDeposits_apiError_returnsFallbackDeposit() { + // Arrange + UUID portfolioUuid = UUID.randomUUID(); + OffsetDateTime activated = OffsetDateTime.now().minusMonths(6); + PortfolioList portfolio = buildPortfolioList(portfolioUuid, "EXT-DEP-ERR", activated); + + when(paymentsApi.listDeposits(isNull(), isNull(), isNull(), isNull(), isNull(), + isNull(), eq(portfolioUuid), isNull(), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("API unavailable"))); + + // Act & Assert + StepVerifier.create(service.upsertDeposits(portfolio)) + .expectNextMatches(d -> portfolioUuid.equals(d.getPortfolio()) + && d.getAmount() == 10_000d) + .verifyComplete(); + + verify(paymentsApi, never()).createDeposit(any()); + } } - @Test - void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { - UUID portfolioUuid1 = UUID.randomUUID(); - UUID portfolioUuid2 = UUID.randomUUID(); - String externalId1 = "PORTFOLIO-EXT-001"; - String externalId2 = "PORTFOLIO-EXT-002"; - - // Mock portfolio lookups — uses externalId, not uuid setter - mockPortfolioFound(externalId1, portfolioUuid1); - mockPortfolioFound(externalId2, portfolioUuid2); - - // Account 1: list returns empty → create fails - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-FAIL"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); - when(portfolioTradingAccountsApi.createPortfolioTradingAccount( - argThat(r -> r != null && "EXT-FAIL".equals(r.getExternalAccountId())))) - .thenReturn(Mono.error(new RuntimeException("Create failed"))); - - // Account 2: list returns empty → create succeeds - UUID createdUuid = UUID.randomUUID(); - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); - when(created.getUuid()).thenReturn(createdUuid); - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-OK"), isNull(), isNull(), isNull())) - .thenReturn(Mono.just(new PaginatedPortfolioTradingAccountList().results(List.of()))); - when(portfolioTradingAccountsApi.createPortfolioTradingAccount( - argThat(r -> r != null && "EXT-OK".equals(r.getExternalAccountId())))) - .thenReturn(Mono.just(created)); - - List input = List.of( - buildTradingAccountInput("EXT-FAIL", externalId1), - buildTradingAccountInput("EXT-OK", externalId2) - ); - - StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) - .expectNextMatches(list -> list.size() == 1 && list.get(0).getUuid().equals(createdUuid)) - .verifyComplete(); - } - - @Test - void upsertPortfolioTradingAccounts_allFail_returnsEmptyList() { - String externalId = "PORTFOLIO-EXT-ALL-FAIL"; - UUID portfolioUuid = UUID.randomUUID(); - - mockPortfolioFound(externalId, portfolioUuid); - - when(portfolioTradingAccountsApi.listPortfolioTradingAccounts( - eq(1), isNull(), isNull(), eq("EXT-ALL-FAIL"), isNull(), isNull(), isNull())) - .thenReturn(Mono.error(new RuntimeException("List failed"))); - - List input = List.of( - buildTradingAccountInput("EXT-ALL-FAIL", externalId) - ); - - StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) - .expectNextMatches(List::isEmpty) - .verifyComplete(); + // ========================================================================= + // upsertInvestmentProducts + // ========================================================================= + + /** + * Tests for {@link InvestmentPortfolioService#upsertInvestmentProducts(InvestmentData, List)}. + * + *

Covers: + *

    + *
  • Unknown product type → {@link IllegalStateException}
  • + *
  • SELF_TRADING type, existing product found → patch
  • + *
  • SELF_TRADING type, no existing product → create
  • + *
  • Non-SELF_TRADING (ROBO) type, model portfolio found → create with model
  • + *
  • Non-SELF_TRADING (ROBO) type, no model portfolio → {@link IllegalStateException}
  • + *
  • Multiple arrangements with same product type → deduplicated to single product
  • + *
  • Patch product fails with {@link WebClientResponseException} → falls back to existing
  • + *
  • Null arrangements list → {@link NullPointerException}
  • + *
+ */ + @Nested + @DisplayName("upsertInvestmentProducts") + class UpsertInvestmentProductsTests { + + @Test + @DisplayName("unknown product type — returns IllegalStateException") + void upsertInvestmentProducts_unknownProductType_returnsError() { + // Arrange + InvestmentArrangement arrangement = buildArrangementWithProductType( + "ARR-UNKNOWN-TYPE", "Unknown Type Arrangement", "UNKNOWN_TYPE"); + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectErrorMatches(e -> e instanceof IllegalStateException) + .verify(); + } + + @Test + @DisplayName("SELF_TRADING type with existing product — patches and returns existing product") + void upsertInvestmentProducts_selfTradingType_existingProduct_patchesAndReturns() { + // Arrange + UUID productUuid = UUID.randomUUID(); + String externalId = "ARR-SELF-TRADING-PATCH"; + InvestmentArrangement arrangement = buildArrangementWithProductType( + externalId, "Self Trading Patch", ProductTypeEnum.SELF_TRADING.getValue()); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of()); + + PortfolioProduct existingProduct = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); + PortfolioProduct patched = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); + PortfolioProduct fallbackCreated = buildPortfolioProduct(UUID.randomUUID(), ProductTypeEnum.SELF_TRADING); + + PaginatedPortfolioProductList productList = Mockito.mock(PaginatedPortfolioProductList.class); + when(productList.getResults()).thenReturn(List.of(existingProduct)); + when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), + eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), + eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())))) + .thenReturn(Mono.just(productList)); + when(productsApi.createPortfolioProduct(any(), any(), isNull(), isNull())) + .thenReturn(Mono.just(fallbackCreated)); + when(productsApi.patchPortfolioProduct( + eq(productUuid.toString()), any(), isNull(), isNull(), any())) + .thenReturn(Mono.just(patched)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectNextMatches(products -> products.size() == 1 + && productUuid.equals(products.get(0).getUuid())) + .verifyComplete(); + + verify(productsApi).patchPortfolioProduct( + eq(productUuid.toString()), any(), isNull(), isNull(), any()); + verify(productsApi, never()).createPortfolioProduct(any(), any(), any(), any()); + } + + @Test + @DisplayName("SELF_TRADING type with no existing product — creates new product") + void upsertInvestmentProducts_selfTradingType_noExistingProduct_createsNew() { + // Arrange + UUID newProductUuid = UUID.randomUUID(); + String externalId = "ARR-SELF-TRADING-NEW"; + InvestmentArrangement arrangement = buildArrangementWithProductType( + externalId, "Self Trading New", ProductTypeEnum.SELF_TRADING.getValue()); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of()); + + PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + when(emptyList.getResults()).thenReturn(List.of()); + when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), + eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), + eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())))) + .thenReturn(Mono.just(emptyList)); + + PortfolioProduct created = buildPortfolioProduct(newProductUuid, ProductTypeEnum.SELF_TRADING); + when(productsApi.createPortfolioProduct(any(), any(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectNextMatches(products -> products.size() == 1 + && newProductUuid.equals(products.get(0).getUuid())) + .verifyComplete(); + + verify(productsApi).createPortfolioProduct(any(), any(), isNull(), isNull()); + verify(productsApi, never()).patchPortfolioProduct(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("ROBO type with model portfolio found — creates product linked to model portfolio") + void upsertInvestmentProducts_roboType_withModelPortfolio_createsProductWithModel() { + // Arrange + UUID newProductUuid = UUID.randomUUID(); + UUID modelUuid = UUID.randomUUID(); + String externalId = "ARR-ROBO-001"; + InvestmentArrangement arrangement = buildArrangementWithProductType( + externalId, "Robo Arrangement", ProductTypeEnum.ROBO_ADVISOR.getValue()); + + ModelPortfolio modelPortfolio = Mockito.mock(ModelPortfolio.class); + when(modelPortfolio.getUuid()).thenReturn(modelUuid); + when(modelPortfolio.getRiskLevel()).thenReturn(3); + when(modelPortfolio.getProductTypeEnum()).thenReturn(ProductTypeEnum.ROBO_ADVISOR); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of(modelPortfolio)); + + PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + when(emptyList.getResults()).thenReturn(List.of()); + when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), + eq(1), isNull(), isNull(), eq(3), isNull(), isNull(), any(), + eq(List.of(ProductTypeEnum.ROBO_ADVISOR.getValue())))) + .thenReturn(Mono.just(emptyList)); + + PortfolioProduct created = buildPortfolioProduct(newProductUuid, ProductTypeEnum.ROBO_ADVISOR); + when(productsApi.createPortfolioProduct(any(), any(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectNextMatches(products -> products.size() == 1 + && newProductUuid.equals(products.get(0).getUuid())) + .verifyComplete(); + + verify(productsApi).createPortfolioProduct(any(), any(), isNull(), isNull()); + } + + @Test + @DisplayName("ROBO type with no model portfolio — returns IllegalStateException") + void upsertInvestmentProducts_roboType_noModelPortfolio_returnsError() { + // Arrange + String externalId = "ARR-ROBO-NO-MODEL"; + InvestmentArrangement arrangement = buildArrangementWithProductType( + externalId, "Robo No Model", ProductTypeEnum.ROBO_ADVISOR.getValue()); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of()); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectErrorMatches(e -> e instanceof IllegalStateException) + .verify(); + } + + @Test + @DisplayName("two arrangements with same SELF_TRADING product type — deduplicated to single product") + void upsertInvestmentProducts_multipleArrangementsWithSameProductType_deduplicatesToOneProduct() { + // Arrange + UUID productUuid = UUID.randomUUID(); + InvestmentArrangement arr1 = buildArrangementWithProductType( + "ARR-DEDUP-001", "Dedup 1", ProductTypeEnum.SELF_TRADING.getValue()); + InvestmentArrangement arr2 = buildArrangementWithProductType( + "ARR-DEDUP-002", "Dedup 2", ProductTypeEnum.SELF_TRADING.getValue()); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of()); + + PaginatedPortfolioProductList emptyList = Mockito.mock(PaginatedPortfolioProductList.class); + when(emptyList.getResults()).thenReturn(List.of()); + when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), + eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), + eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())))) + .thenReturn(Mono.just(emptyList)); + + PortfolioProduct created = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); + when(productsApi.createPortfolioProduct(any(), any(), isNull(), isNull())) + .thenReturn(Mono.just(created)); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arr1, arr2))) + .expectNextMatches(products -> products.size() == 1) + .verifyComplete(); + + verify(productsApi, times(1)).createPortfolioProduct(any(), any(), isNull(), isNull()); + } + + @Test + @DisplayName("patch product fails with WebClientResponseException — falls back to existing product") + void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExisting() { + // Arrange + UUID productUuid = UUID.randomUUID(); + InvestmentArrangement arrangement = buildArrangementWithProductType( + "ARR-PATCH-FAIL", "Patch Fail", ProductTypeEnum.SELF_TRADING.getValue()); + + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + when(investmentData.getModelPortfolios()).thenReturn(List.of()); + + PortfolioProduct existingProduct = buildPortfolioProduct(productUuid, ProductTypeEnum.SELF_TRADING); + + PaginatedPortfolioProductList productList = Mockito.mock(PaginatedPortfolioProductList.class); + when(productList.getResults()).thenReturn(List.of(existingProduct)); + when(productsApi.listPortfolioProducts(any(), isNull(), isNull(), + eq(1), isNull(), isNull(), isNull(), isNull(), isNull(), any(), + eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())))) + .thenReturn(Mono.just(productList)); + when(productsApi.patchPortfolioProduct( + eq(productUuid.toString()), any(), isNull(), isNull(), any())) + .thenReturn(Mono.error(WebClientResponseException.create( + HttpStatus.UNPROCESSABLE_ENTITY.value(), "Unprocessable Entity", + HttpHeaders.EMPTY, "patch error".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8))); + + // Act & Assert + StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) + .expectNextMatches(products -> products.size() == 1 + && productUuid.equals(products.get(0).getUuid())) + .verifyComplete(); + + verify(productsApi, never()).createPortfolioProduct(any(), any(), any(), any()); + } + + @Test + @DisplayName("null arrangements list — throws NullPointerException immediately") + void upsertInvestmentProducts_nullArrangements_throwsNullPointerException() { + InvestmentData investmentData = Mockito.mock(InvestmentData.class); + assertThrows(NullPointerException.class, + () -> service.upsertInvestmentProducts(investmentData, null)); + } } - // ----------------------------------------------------------------------- - // createPortfolioTradingAccount — direct creation - // ----------------------------------------------------------------------- - - @Test - void createPortfolioTradingAccount_success_returnsCreatedAccount() { - UUID portfolioUuid = UUID.randomUUID(); - UUID newUuid = UUID.randomUUID(); - - PortfolioTradingAccount created = Mockito.mock(PortfolioTradingAccount.class); - when(created.getUuid()).thenReturn(newUuid); - when(created.getExternalAccountId()).thenReturn("EXT-NEW"); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-NEW") - .accountId("ACC-NEW") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) - .thenReturn(Mono.just(created)); - - StepVerifier.create(service.createPortfolioTradingAccount(request)) - .expectNextMatches(a -> a.getUuid().equals(newUuid)) - .verifyComplete(); - } - - @Test - void createPortfolioTradingAccount_apiFails_propagatesError() { - UUID portfolioUuid = UUID.randomUUID(); - - PortfolioTradingAccountRequest request = new PortfolioTradingAccountRequest() - .externalAccountId("EXT-ERR") - .accountId("ACC-ERR") - .portfolio(portfolioUuid) - .isDefault(false) - .isInternal(false); - - when(portfolioTradingAccountsApi.createPortfolioTradingAccount((request))) - .thenReturn(Mono.error(new RuntimeException("Creation failed"))); - - StepVerifier.create(service.createPortfolioTradingAccount(request)) - .expectErrorMatches(e -> e instanceof RuntimeException - && e.getMessage().equals("Creation failed")) - .verify(); - } - - // ----------------------------------------------------------------------- + // ========================================================================= // Helpers - // ----------------------------------------------------------------------- + // ========================================================================= + /** + * Builds a mocked {@link PortfolioList} with the given UUID, externalId, and activation date. + */ private PortfolioList buildPortfolioList(UUID portfolioUuid, String externalId, OffsetDateTime activated) { PortfolioList portfolio = Mockito.mock(PortfolioList.class); when(portfolio.getUuid()).thenReturn(portfolioUuid); @@ -357,8 +1134,61 @@ private PortfolioList buildPortfolioList(UUID portfolioUuid, String externalId, return portfolio; } + /** + * Builds a mocked {@link PortfolioProduct} with the given UUID and product type. + * Model portfolio and advice engine are set to null for SELF_TRADING; callers + * should override these stubs for non-SELF_TRADING types. + */ + private PortfolioProduct buildPortfolioProduct(UUID uuid, ProductTypeEnum productType) { + PortfolioProduct product = Mockito.mock(PortfolioProduct.class); + when(product.getUuid()).thenReturn(uuid); + when(product.getProductType()).thenReturn(productType); + when(product.getAdviceEngine()).thenReturn(null); + when(product.getModelPortfolio()).thenReturn(null); + when(product.getExtraData()).thenReturn(null); + return product; + } + + /** + * Builds a mocked {@link InvestmentArrangement} with product ID and legal entity external ID. + * Used for portfolio upsert tests. + */ + private InvestmentArrangement buildArrangement(String externalId, String name, + UUID productId, String legalEntityExternalId) { + InvestmentArrangement arrangement = Mockito.mock(InvestmentArrangement.class); + when(arrangement.getExternalId()).thenReturn(externalId); + when(arrangement.getName()).thenReturn(name); + when(arrangement.getInvestmentProductId()).thenReturn(productId); + when(arrangement.getLegalEntityExternalIds()).thenReturn(List.of(legalEntityExternalId)); + when(arrangement.getCurrency()).thenReturn("EUR"); + when(arrangement.getInternalId()).thenReturn(null); + return arrangement; + } + + /** + * Builds a mocked {@link InvestmentArrangement} with a product type external ID. + * Used for investment product upsert tests. + */ + private InvestmentArrangement buildArrangementWithProductType(String externalId, String name, + String productTypeValue) { + InvestmentArrangement arrangement = Mockito.mock(InvestmentArrangement.class); + when(arrangement.getExternalId()).thenReturn(externalId); + when(arrangement.getName()).thenReturn(name); + when(arrangement.getProductTypeExternalId()).thenReturn(productTypeValue); + when(arrangement.getLegalEntityExternalIds()).thenReturn(List.of()); + when(arrangement.getCurrency()).thenReturn("EUR"); + when(arrangement.getInternalId()).thenReturn(null); + return arrangement; + } + + + /** + * Stubs {@link PortfolioApi#listPortfolios} to return an existing portfolio with the given UUID. + * Used in trading account tests that need a resolved portfolio UUID. + */ private void mockPortfolioFound(String externalId, UUID portfolioUuid) { - PortfolioList portfolioList = buildPortfolioList(portfolioUuid, externalId, OffsetDateTime.now().minusMonths(6)); + PortfolioList portfolioList = buildPortfolioList(portfolioUuid, externalId, + OffsetDateTime.now().minusMonths(6)); PaginatedPortfolioListList paginatedList = Mockito.mock(PaginatedPortfolioListList.class); when(paginatedList.getResults()).thenReturn(List.of(portfolioList)); @@ -367,15 +1197,4 @@ private void mockPortfolioFound(String externalId, UUID portfolioUuid) { isNull(), isNull(), isNull(), isNull())) .thenReturn(Mono.just(paginatedList)); } - - private InvestmentPortfolioTradingAccount buildTradingAccountInput(String externalAccountId, - String portfolioExternalId) { - return InvestmentPortfolioTradingAccount.builder() - .accountExternalId(externalAccountId) - .portfolioExternalId(portfolioExternalId) - .accountId("ACC-" + externalAccountId) - .isDefault(false) - .isInternal(false) - .build(); - } -} \ No newline at end of file +} diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRestNewsContentServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRestNewsContentServiceTest.java deleted file mode 100644 index 692c4029a..000000000 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentRestNewsContentServiceTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.backbase.stream.investment.service; - -import com.backbase.investment.api.service.sync.ApiClient; -import com.backbase.investment.api.service.sync.v1.ContentApi; -import com.backbase.investment.api.service.v1.model.EntryCreateUpdate; -import com.backbase.investment.api.service.v1.model.EntryCreateUpdateRequest; -import com.backbase.investment.api.service.v1.model.PaginatedEntryList; -import com.backbase.stream.investment.service.resttemplate.InvestmentRestNewsContentService; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -class InvestmentRestNewsContentServiceTest { - - private ContentApi contentApi; - private ApiClient apiClient; - private InvestmentRestNewsContentService service; - - @BeforeEach - void setUp() { - contentApi = Mockito.mock(ContentApi.class); - apiClient = Mockito.mock(ApiClient.class); - service = new InvestmentRestNewsContentService(contentApi, apiClient); - } - - @Test - void upsertContent_createsNewEntry_whenNotExists() { - // Given - EntryCreateUpdateRequest request = new EntryCreateUpdateRequest() - .title("New Article") - .excerpt("Excerpt") - .tags(List.of("tag1")); - - PaginatedEntryList emptyList = new PaginatedEntryList() - .count(0) - .results(List.of()); - - EntryCreateUpdate created = new EntryCreateUpdate(); - - } - - -} - diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseServiceTest.java new file mode 100644 index 000000000..57301c021 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestAssetUniverseServiceTest.java @@ -0,0 +1,355 @@ +package com.backbase.stream.investment.service.resttemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.sync.ApiClient; +import com.backbase.investment.api.service.sync.v1.AssetUniverseApi; +import com.backbase.investment.api.service.sync.v1.model.AssetCategory; +import com.backbase.stream.investment.Asset; +import com.backbase.stream.investment.model.AssetCategoryEntry; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class InvestmentRestAssetUniverseServiceTest { + + @Mock + private AssetUniverseApi assetUniverseApi; + + @Mock + private ApiClient apiClient; + + private InvestmentRestAssetUniverseService service; + + @BeforeEach + void setUp() { + service = new InvestmentRestAssetUniverseService(assetUniverseApi, apiClient); + } + + // ----------------------------------------------------------------------- + // createAsset + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("createAsset") + class CreateAsset { + + @Test + @DisplayName("successful create maps and returns stream asset") + void successfulCreateReturnsMappedAsset() { + Asset asset = new Asset(); + asset.setIsin("ISIN001"); + asset.setMarket("XNAS"); + asset.setCurrency("USD"); + asset.setName("TestAsset"); + + UUID syncUuid = UUID.randomUUID(); + com.backbase.investment.api.service.sync.v1.model.Asset syncAsset = + new com.backbase.investment.api.service.sync.v1.model.Asset(syncUuid); + syncAsset.setName("TestAsset"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any())).thenReturn(new ResponseEntity<>(syncAsset, HttpStatus.OK)); + + StepVerifier.create(service.createAsset(asset, Map.of())) + .assertNext(result -> { + assertThat(result.getName()).isEqualTo("TestAsset"); + assertThat(result.getUuid()).isEqualTo(syncUuid); + }) + .verifyComplete(); + } + + @Test + @DisplayName("API failure falls back to original asset and completes normally") + void apiFailureFallsBackToOriginalAsset() { + Asset asset = new Asset(); + asset.setIsin("ISIN001"); + asset.setMarket("XNAS"); + asset.setCurrency("USD"); + asset.setName("OriginalName"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any())).thenThrow(new RuntimeException("Network error")); + + StepVerifier.create(service.createAsset(asset, Map.of())) + .assertNext(result -> assertThat(result).isSameAs(asset)) + .verifyComplete(); + } + + @Test + @DisplayName("asset with logo file passes logo to form params") + void assetWithLogoFileIncludesLogoInRequest() { + Asset asset = new Asset(); + asset.setIsin("ISIN001"); + asset.setMarket("XNAS"); + asset.setCurrency("USD"); + asset.setName("AssetWithLogo"); + Resource logo = new ByteArrayResource("logo-bytes".getBytes()) { + @Override + public String getFilename() { + return "logo.png"; + } + }; + asset.setLogoFile(logo); + + com.backbase.investment.api.service.sync.v1.model.Asset syncAsset = + new com.backbase.investment.api.service.sync.v1.model.Asset(UUID.randomUUID()); + syncAsset.setName("AssetWithLogo"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any())).thenReturn(new ResponseEntity<>(syncAsset, HttpStatus.OK)); + + StepVerifier.create(service.createAsset(asset, Map.of())) + .assertNext(result -> assertThat(result.getName()).isEqualTo("AssetWithLogo")) + .verifyComplete(); + } + } + + // ----------------------------------------------------------------------- + // patchAsset (reactive version) + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("patchAsset (reactive)") + class PatchAsset { + + @Test + @DisplayName("successful patch returns original stream asset") + void successfulPatchReturnsOriginalAsset() { + UUID existUuid = UUID.randomUUID(); + final com.backbase.investment.api.service.v1.model.Asset existAsset = + new com.backbase.investment.api.service.v1.model.Asset(existUuid); + + Asset streamAsset = new Asset(); + streamAsset.setIsin("ISIN001"); + streamAsset.setMarket("XNAS"); + streamAsset.setCurrency("USD"); + streamAsset.setName("PatchedAsset"); + + com.backbase.investment.api.service.sync.v1.model.Asset syncPatched = + new com.backbase.investment.api.service.sync.v1.model.Asset(existUuid); + syncPatched.setName("PatchedAsset"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any())).thenReturn(new ResponseEntity<>(syncPatched, HttpStatus.OK)); + + StepVerifier.create(service.patchAsset(existAsset, streamAsset, Map.of())) + .assertNext(result -> assertThat(result).isSameAs(streamAsset)) + .verifyComplete(); + } + + @Test + @DisplayName("API failure on patch falls back to original asset and completes normally") + void apiFailureOnPatchFallsBackToOriginalAsset() { + final com.backbase.investment.api.service.v1.model.Asset existAsset = + new com.backbase.investment.api.service.v1.model.Asset(UUID.randomUUID()); + + Asset streamAsset = new Asset(); + streamAsset.setIsin("ISIN001"); + streamAsset.setMarket("XNAS"); + streamAsset.setCurrency("USD"); + streamAsset.setName("OriginalName"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any())).thenThrow(new RuntimeException("Network error")); + + StepVerifier.create(service.patchAsset(existAsset, streamAsset, Map.of())) + .assertNext(result -> assertThat(result).isSameAs(streamAsset)) + .verifyComplete(); + } + } + + // ----------------------------------------------------------------------- + // setAssetCategoryLogo + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("setAssetCategoryLogo") + class SetAssetCategoryLogo { + + @Test + @DisplayName("null logo returns Mono.empty()") + void nullLogoReturnsEmpty() { + UUID categoryId = UUID.randomUUID(); + + StepVerifier.create(service.setAssetCategoryLogo(categoryId, null)) + .verifyComplete(); + + verify(apiClient, never()).invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any()); + } + + @Test + @DisplayName("logo provided sends PATCH and returns category UUID") + void logoProvidedReturnsCategoryUuid() { + UUID categoryId = UUID.randomUUID(); + Resource logo = new ByteArrayResource("img".getBytes()) { + @Override + public String getFilename() { + return "cat.png"; + } + }; + + AssetCategory patchedCategory = new AssetCategory(categoryId); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(patchedCategory, HttpStatus.OK)); + + StepVerifier.create(service.setAssetCategoryLogo(categoryId, logo)) + .assertNext(result -> assertThat(result).isEqualTo(categoryId)) + .verifyComplete(); + } + + @Test + @DisplayName("API failure falls back to original category ID and completes normally") + void apiFailureFallsBackToCategoryId() { + UUID categoryId = UUID.randomUUID(); + Resource logo = new ByteArrayResource("img".getBytes()) { + @Override + public String getFilename() { + return "cat.png"; + } + }; + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenThrow(new RuntimeException("Network error")); + + StepVerifier.create(service.setAssetCategoryLogo(categoryId, logo)) + .assertNext(result -> assertThat(result).isEqualTo(categoryId)) + .verifyComplete(); + } + } + + // ----------------------------------------------------------------------- + // createAssetCategory + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("createAssetCategory") + class CreateAssetCategory { + + @Test + @DisplayName("successful create returns AssetCategory") + void successfulCreateReturnsCategory() { + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setName("Tech"); + entry.setCode("TECH"); + + UUID createdUuid = UUID.randomUUID(); + AssetCategory created = new AssetCategory(createdUuid); + created.setName("Tech"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(created, HttpStatus.OK)); + + StepVerifier.create(service.createAssetCategory(entry, null)) + .assertNext(result -> { + assertThat(result.getName()).isEqualTo("Tech"); + assertThat(result.getUuid()).isEqualTo(createdUuid); + }) + .verifyComplete(); + } + + @Test + @DisplayName("category with image passes image in form params") + void categoryWithImageIncludesImageInRequest() { + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setName("Bonds"); + entry.setCode("BONDS"); + + final Resource image = new ByteArrayResource("img-data".getBytes()) { + @Override + public String getFilename() { + return "bonds.png"; + } + }; + + AssetCategory created = new AssetCategory(UUID.randomUUID()); + created.setName("Bonds"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(created, HttpStatus.OK)); + + StepVerifier.create(service.createAssetCategory(entry, image)) + .assertNext(result -> assertThat(result.getName()).isEqualTo("Bonds")) + .verifyComplete(); + } + } + + // ----------------------------------------------------------------------- + // patchAssetCategory + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("patchAssetCategory") + class PatchAssetCategoryTest { + + @Test + @DisplayName("successful patch returns updated AssetCategory") + void successfulPatchReturnsUpdatedCategory() { + UUID categoryId = UUID.randomUUID(); + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setName("FixedIncome"); + entry.setCode("FIXED"); + + AssetCategory patched = new AssetCategory(categoryId); + patched.setName("FixedIncome"); + + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(patched, HttpStatus.OK)); + + StepVerifier.create(service.patchAssetCategory(categoryId, entry, null)) + .assertNext(result -> { + assertThat(result.getUuid()).isEqualTo(categoryId); + assertThat(result.getName()).isEqualTo("FixedIncome"); + }) + .verifyComplete(); + } + } + + // ----------------------------------------------------------------------- + // getFileNameForLog (static utility) + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("getFileNameForLog") + class GetFileNameForLog { + + @Test + @DisplayName("null resource returns 'null' string") + void nullResourceReturnsNullString() { + assertThat(InvestmentRestAssetUniverseService.getFileNameForLog(null)).isEqualTo("null"); + } + + @Test + @DisplayName("resource with filename returns filename") + void resourceWithFilenameReturnsFilename() { + Resource logo = new ByteArrayResource("data".getBytes()) { + @Override + public String getFilename() { + return "my-logo.png"; + } + }; + assertThat(InvestmentRestAssetUniverseService.getFileNameForLog(logo)).isEqualTo("my-logo.png"); + } + } +} + diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java new file mode 100644 index 000000000..3012feb14 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentServiceTest.java @@ -0,0 +1,262 @@ +package com.backbase.stream.investment.service.resttemplate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.sync.ApiClient; +import com.backbase.investment.api.service.sync.v1.ContentApi; +import com.backbase.investment.api.service.sync.v1.model.DocumentTag; +import com.backbase.investment.api.service.sync.v1.model.OASDocumentResponse; +import com.backbase.investment.api.service.sync.v1.model.PaginatedDocumentTagList; +import com.backbase.investment.api.service.sync.v1.model.PaginatedOASDocumentResponseList; +import com.backbase.stream.investment.model.ContentDocumentEntry; +import com.backbase.stream.investment.model.ContentTag; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class InvestmentRestDocumentContentServiceTest { + + @Mock + private ContentApi contentApi; + + @Mock + private ApiClient apiClient; + + private InvestmentRestDocumentContentService service; + + @BeforeEach + void setUp() { + service = new InvestmentRestDocumentContentService(contentApi, apiClient); + } + + static Stream invalidTags() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of(" ", "value1"), // blank code + org.junit.jupiter.params.provider.Arguments.of("code1", null), // null value + org.junit.jupiter.params.provider.Arguments.of("code1", " ") // blank value + ); + } + + // ----------------------------------------------------------------------- + // upsertContentTags + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("upsertContentTags") + class UpsertContentTags { + + @Test + @DisplayName("empty list completes without calling API") + void emptyListCompletesWithoutApiCall() { + StepVerifier.create(service.upsertContentTags(List.of())) + .verifyComplete(); + + verify(contentApi, never()).contentDocumentTagList(anyInt(), anyInt()); + } + + @Test + @DisplayName("tag with null code is skipped") + void tagWithNullCodeIsSkipped() { + ContentTag tag = new ContentTag(null, "someValue"); + + StepVerifier.create(service.upsertContentTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, never()).contentDocumentTagList(anyInt(), anyInt()); + verify(contentApi, never()).contentDocumentTagCreate(any()); + verify(contentApi, never()).contentDocumentTagPartialUpdate(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.InvestmentRestDocumentContentServiceTest#invalidTags") + void tagWithInvalidCodeOrValueIsSkipped(String code, String value) { + ContentTag tag = new ContentTag(code, value); + + StepVerifier.create(service.upsertContentTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, never()).contentDocumentTagList(anyInt(), anyInt()); + verify(contentApi, never()).contentDocumentTagCreate(any()); + verify(contentApi, never()).contentDocumentTagPartialUpdate(any(), any()); + } + + @Test + @DisplayName("new tag (not existing) triggers create") + void newTagTriggersCreate() { + ContentTag tag = new ContentTag("docCode1", "docValue1"); + PaginatedDocumentTagList emptyPage = new PaginatedDocumentTagList().results(List.of()); + DocumentTag created = new DocumentTag().code("docCode1").value("docValue1"); + + when(contentApi.contentDocumentTagList(anyInt(), anyInt())).thenReturn(emptyPage); + when(contentApi.contentDocumentTagCreate(any())).thenReturn(created); + + StepVerifier.create(service.upsertContentTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, times(1)).contentDocumentTagCreate(any()); + verify(contentApi, never()).contentDocumentTagPartialUpdate(any(), any()); + } + + @Test + @DisplayName("existing tag triggers patch") + void existingTagTriggersPatch() { + ContentTag tag = new ContentTag("docCode1", "newValue"); + DocumentTag existingTag = new DocumentTag().code("docCode1").value("oldValue"); + PaginatedDocumentTagList page = new PaginatedDocumentTagList().results(List.of(existingTag)); + DocumentTag patched = new DocumentTag().code("docCode1").value("newValue"); + + when(contentApi.contentDocumentTagList(anyInt(), anyInt())).thenReturn(page); + when(contentApi.contentDocumentTagPartialUpdate(anyString(), any())).thenReturn(patched); + + StepVerifier.create(service.upsertContentTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, times(1)).contentDocumentTagPartialUpdate(anyString(), any()); + verify(contentApi, never()).contentDocumentTagCreate(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"); + PaginatedDocumentTagList emptyPage = new PaginatedDocumentTagList().results(List.of()); + DocumentTag created = new DocumentTag().code("code2").value("value2"); + + when(contentApi.contentDocumentTagList(anyInt(), anyInt())).thenReturn(emptyPage); + when(contentApi.contentDocumentTagCreate(any())) + .thenThrow(new RuntimeException("API error")) + .thenReturn(created); + + StepVerifier.create(service.upsertContentTags(List.of(tag1, tag2))) + .verifyComplete(); + + verify(contentApi, times(2)).contentDocumentTagCreate(any()); + } + + @Test + @DisplayName("multiple tags: existing gets patched, new gets created") + void multipleTagsMixedUpsert() { + ContentTag existTag = new ContentTag("code1", "v1"); + ContentTag newTag = new ContentTag("code2", "v2"); + DocumentTag existing = new DocumentTag().code("code1").value("oldV1"); + PaginatedDocumentTagList page = new PaginatedDocumentTagList().results(List.of(existing)); + DocumentTag patched = new DocumentTag().code("code1").value("v1"); + DocumentTag created = new DocumentTag().code("code2").value("v2"); + + when(contentApi.contentDocumentTagList(anyInt(), anyInt())).thenReturn(page); + when(contentApi.contentDocumentTagPartialUpdate(anyString(), any())).thenReturn(patched); + when(contentApi.contentDocumentTagCreate(any())).thenReturn(created); + + StepVerifier.create(service.upsertContentTags(List.of(existTag, newTag))) + .verifyComplete(); + + verify(contentApi, times(1)).contentDocumentTagPartialUpdate(anyString(), any()); + verify(contentApi, times(1)).contentDocumentTagCreate(any()); + } + } + + // ----------------------------------------------------------------------- + // upsertDocuments + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("upsertDocuments") + class UpsertDocuments { + + @Test + @DisplayName("empty list completes without calling API") + void emptyListCompletesWithoutApiCall() { + PaginatedOASDocumentResponseList emptyPage = new PaginatedOASDocumentResponseList().results(List.of()); + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(emptyPage); + + StepVerifier.create(service.upsertDocuments(List.of())) + .verifyComplete(); + + verify(apiClient, never()).invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any()); + } + + @Test + @DisplayName("no existing documents - all documents are inserted via apiClient") + void noExistingDocumentsAllInserted() { + final ContentDocumentEntry doc = ContentDocumentEntry.builder().name("Doc 1").build(); + PaginatedOASDocumentResponseList emptyPage = new PaginatedOASDocumentResponseList().results(List.of()); + // OASDocumentResponse: uuid is constructor-only; name has setName() + OASDocumentResponse created = new OASDocumentResponse(UUID.randomUUID()); + created.setName("Doc 1"); + + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(emptyPage); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(created, HttpStatus.OK)); + + StepVerifier.create(service.upsertDocuments(List.of(doc))) + .verifyComplete(); + } + + @Test + @DisplayName("existing document by name triggers patch via apiClient") + void existingDocumentTriggersPatch() { + UUID existingUuid = UUID.randomUUID(); + final ContentDocumentEntry doc = ContentDocumentEntry.builder().name("Existing Doc").build(); + OASDocumentResponse existingResponse = new OASDocumentResponse(existingUuid); + existingResponse.setName("Existing Doc"); + PaginatedOASDocumentResponseList page = new PaginatedOASDocumentResponseList() + .results(List.of(existingResponse)); + OASDocumentResponse patched = new OASDocumentResponse(existingUuid); + patched.setName("Existing Doc"); + + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(page); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(new ResponseEntity<>(patched, HttpStatus.OK)); + + StepVerifier.create(service.upsertDocuments(List.of(doc))) + .verifyComplete(); + } + + @Test + @DisplayName("API failure during document insert is swallowed and processing continues") + void apiFailureIsSwallowedAndProcessingContinues() { + final ContentDocumentEntry doc1 = ContentDocumentEntry.builder().name("Doc 1").build(); + final ContentDocumentEntry doc2 = ContentDocumentEntry.builder().name("Doc 2").build(); + PaginatedOASDocumentResponseList emptyPage = new PaginatedOASDocumentResponseList().results(List.of()); + OASDocumentResponse created = new OASDocumentResponse(UUID.randomUUID()); + created.setName("Doc 2"); + + when(contentApi.listContentDocuments(isNull(), anyInt(), isNull(), anyInt(), isNull(), isNull())) + .thenReturn(emptyPage); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), isNull(), any(), any(), any(), any(), any(), + any(), any())) + .thenThrow(new RuntimeException("API failure")) + .thenReturn(new ResponseEntity<>(created, HttpStatus.OK)); + + StepVerifier.create(service.upsertDocuments(List.of(doc1, doc2))) + .verifyComplete(); + } + } +} + 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 new file mode 100644 index 000000000..741575c18 --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestNewsContentServiceTest.java @@ -0,0 +1,312 @@ +package com.backbase.stream.investment.service.resttemplate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.sync.ApiClient; +import com.backbase.investment.api.service.sync.v1.ContentApi; +import com.backbase.investment.api.service.sync.v1.model.Entry; +import com.backbase.investment.api.service.sync.v1.model.EntryCreateUpdate; +import com.backbase.investment.api.service.sync.v1.model.EntryTag; +import com.backbase.investment.api.service.sync.v1.model.PaginatedEntryList; +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 java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class InvestmentRestNewsContentServiceTest { + + @Mock + private ContentApi contentApi; + + @Mock + private ApiClient apiClient; + + private InvestmentRestNewsContentService service; + + @BeforeEach + void setUp() { + service = new InvestmentRestNewsContentService(contentApi, apiClient); + } + + 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 + ); + } + + // ----------------------------------------------------------------------- + // upsertTags + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("upsertTags") + class UpsertTags { + + @Test + @DisplayName("empty list completes without calling API") + void emptyListCompletesWithoutApiCall() { + StepVerifier.create(service.upsertTags(List.of())) + .verifyComplete(); + + verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); + } + + @Test + @DisplayName("tag with null code is skipped") + void tagWithNullCodeIsSkipped() { + ContentTag tag = new ContentTag(null, "someValue"); + + StepVerifier.create(service.upsertTags(List.of(tag))) + .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); + + StepVerifier.create(service.upsertTags(List.of(tag))) + .verifyComplete(); + + verify(contentApi, never()).contentEntryTagList(anyInt(), anyInt()); + verify(contentApi, never()).contentEntryTagCreate(any()); + } + + @Test + @DisplayName("new tag (not existing) triggers create") + void newTagTriggersCreate() { + ContentTag tag = new ContentTag("code1", "value1"); + PaginatedEntryTagList emptyPage = new PaginatedEntryTagList().results(List.of()); + EntryTag createdTag = new EntryTag().code("code1").value("value1"); + + when(contentApi.contentEntryTagList(anyInt(), anyInt())).thenReturn(emptyPage); + when(contentApi.contentEntryTagCreate(any())).thenReturn(createdTag); + + StepVerifier.create(service.upsertTags(List.of(tag))) + .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()); + } + } + + // ----------------------------------------------------------------------- + // upsertContent + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("upsertContent") + class UpsertContent { + + @Test + @DisplayName("empty list completes without calling API") + void emptyListCompletesWithoutApiCall() { + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + + StepVerifier.create(service.upsertContent(List.of())) + .verifyComplete(); + + verify(contentApi, never()).createContentEntry(any()); + } + + @Test + @DisplayName("no existing entries - all entries are created") + void noExistingEntriesAllCreated() { + MarketNewsEntry entry = new MarketNewsEntry(); + entry.setTitle("News Title"); + + 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); + + StepVerifier.create(service.upsertContent(List.of(entry))) + .verifyComplete(); + + verify(contentApi, times(1)).createContentEntry(any()); + } + + @Test + @DisplayName("existing entries with matching title are skipped (not duplicated)") + 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)); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(page); + + 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()); + } + + @Test + @DisplayName("API failure on entry create is swallowed and processing continues") + void apiFailureOnCreateIsSwallowed() { + MarketNewsEntry entry1 = new MarketNewsEntry(); + entry1.setTitle("News 1"); + MarketNewsEntry entry2 = new MarketNewsEntry(); + entry2.setTitle("News 2"); + + PaginatedEntryList emptyPage = new PaginatedEntryList().results(List.of()); + EntryCreateUpdate created = new EntryCreateUpdate(UUID.randomUUID(), null, null); + created.setTitle("News 2"); + + when(contentApi.listContentEntries(isNull(), anyInt(), anyInt(), + isNull(), isNull(), isNull(), isNull())).thenReturn(emptyPage); + when(contentApi.createContentEntry(any())) + .thenThrow(new RuntimeException("API failure")) + .thenReturn(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()); + } + } +} + From 8d585ae366a20c6269fe5ed10fe6679f2d3e10b5 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Thu, 12 Mar 2026 18:01:23 +0530 Subject: [PATCH 3/5] minor changes --- .../saga/InvestmentContentSaga.java | 37 +++++++++++++++++++ .../investment/saga/InvestmentSaga.java | 1 - 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java index 0417d54cd..89145bb4f 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentContentSaga.java @@ -15,6 +15,34 @@ import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; +/** + * Saga orchestrating the complete investment content ingestion workflow. + * + *

This saga implements a multi-step process for ingesting investment content data: + *

    + *
  1. Upsert news tags - Creates or updates market news tags
  2. + *
  3. Upsert news content - Creates or updates market news entries
  4. + *
  5. Upsert document tags - Creates or updates content document tags
  6. + *
  7. Upsert content documents - Creates or updates content document entries
  8. + *
+ * + *

The saga uses idempotent operations to ensure safe re-execution and writes progress + * to the {@link com.backbase.stream.worker.model.StreamTask} history for observability. + * Each step builds upon the previous step's results, creating a complete content setup. + * + *

Design notes: + *

    + *
  • All operations are idempotent (safe to retry)
  • + *
  • Progress is tracked via StreamTask state and history
  • + *
  • Failures are logged with complete context for debugging
  • + *
  • All reactive operations include proper success and error handlers
  • + *
  • Content ingestion can be disabled via {@link com.backbase.stream.configuration.InvestmentIngestionConfigurationProperties}
  • + *
+ * + * @see InvestmentRestNewsContentService + * @see InvestmentRestDocumentContentService + * @see StreamTaskExecutor + */ @Slf4j @RequiredArgsConstructor public class InvestmentContentSaga implements StreamTaskExecutor { @@ -142,6 +170,15 @@ private Mono upsertContentDocuments(InvestmentContentTask .thenReturn(investmentContentTask); } + /** + * Rollback is not implemented for investment saga. + * + *

Investment operations are idempotent and designed to be retried safely. + * Manual cleanup should be performed if necessary through the Investment Service API. + * + * @param streamTask the task to rollback + * @return null - rollback not implemented + */ @Override public Mono rollBack(InvestmentContentTask streamTask) { log.warn("Rollback requested for investment saga but not implemented: taskId={}, taskName={}", diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java index 43b2e17bc..b174bbfcd 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java @@ -149,7 +149,6 @@ private Mono upsertPortfoliosAllocations(InvestmentTask investme .map(o -> investmentTask); } - /** * Upserts investment portfolios for all investment arrangements. * From 74659b153ea84a40aa2f2a6b015a9dab3e3ab482 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Mon, 16 Mar 2026 18:13:02 +0530 Subject: [PATCH 4/5] Review comments --- .../InvestmentRestDocumentContentService.java | 6 -- .../InvestmentAssetUniverseServiceTest.java | 73 ++++++++++--------- .../InvestmentPortfolioServiceTest.java | 33 +++++---- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java index a6406e1ac..46cf63227 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestDocumentContentService.java @@ -164,9 +164,6 @@ private Mono patchDocument(UUID uuid, ContentDocumentEntry request.getDocumentResource() != null); OASDocumentRequestDataRequest createDocumentRequest = contentMapper.map(request); log.debug("Document entry request mapped: {}", createDocumentRequest); - // Mono.just would evaluate patchDocument eagerly, throwing before onErrorResume can intercept; - // fromCallable defers execution so any synchronous exception is captured as a reactive error, - // allowing onErrorResume to swallow per-item failures and continue batch processing. return Mono.defer(() -> Mono.fromCallable(() -> patchDocument(uuid, createDocumentRequest, request.getDocumentResource())) .doOnSuccess( created -> log.info("Document entry created successfully: title='{}', uuid={}, documentAttached={}", @@ -184,9 +181,6 @@ private Mono insertDocument(ContentDocumentEntry request) OASDocumentRequestDataRequest createDocumentRequest = contentMapper.map(request); log.debug("Document entry request mapped: {}", createDocumentRequest); - // Mono.just would evaluate createContentDocument eagerly, throwing before onErrorResume can intercept; - // fromCallable defers execution so any synchronous exception is captured as a reactive error, - // allowing onErrorResume to swallow per-item failures and continue batch processing. return Mono.defer(() -> Mono.fromCallable(() -> createContentDocument(createDocumentRequest, request.getDocumentResource())) .doOnSuccess( created -> log.info("Document entry created successfully: title='{}', uuid={}, documentAttached={}", diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java index cd4422e03..ea3be319b 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java @@ -96,7 +96,7 @@ void upsertMarket_marketExists_updateCalledAndReturned() { Market updated = new Market().code("US").name("US Market Updated"); when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); - when(assetUniverseApi.updateMarket(eq("US"), eq(request))).thenReturn(Mono.just(updated)); + when(assetUniverseApi.updateMarket("US", request)).thenReturn(Mono.just(updated)); when(assetUniverseApi.createMarket(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback // Act & Assert @@ -104,7 +104,7 @@ void upsertMarket_marketExists_updateCalledAndReturned() { .expectNext(updated) .verifyComplete(); - verify(assetUniverseApi).updateMarket(eq("US"), eq(request)); + verify(assetUniverseApi).updateMarket("US", request); verify(assetUniverseApi, never()).createMarket(any()); } @@ -153,7 +153,7 @@ void upsertMarket_updateFails_errorPropagated() { Market existing = new Market().code("US").name("US Market"); when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); - when(assetUniverseApi.updateMarket(eq("US"), eq(request))) + when(assetUniverseApi.updateMarket("US", request)) .thenReturn(Mono.error(new RuntimeException("update failed"))); when(assetUniverseApi.createMarket(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback @@ -213,11 +213,11 @@ void getOrCreateAsset_assetExists_patchCalledAndMappedReturned() { .currency("USD"); com.backbase.stream.investment.Asset patchedAsset = buildAsset(); - when(assetUniverseApi.getAsset(eq("ABC123_market_USD"), isNull(), isNull(), isNull())) + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) .thenReturn(Mono.just(existingApiAsset)); when(investmentRestAssetUniverseService.patchAsset(eq(existingApiAsset), eq(req), any())) .thenReturn(Mono.just(patchedAsset)); - when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // Act & Assert StepVerifier.create(service.getOrCreateAsset(req, null)) @@ -237,9 +237,9 @@ void getOrCreateAsset_assetNotFound_createCalledAndReturned() { com.backbase.stream.investment.Asset req = buildAsset(); com.backbase.stream.investment.Asset created = buildAsset(); - when(assetUniverseApi.getAsset(eq("ABC123_market_USD"), isNull(), isNull(), isNull())) + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) .thenReturn(Mono.error(notFound())); - when(investmentRestAssetUniverseService.createAsset(eq(req), eq(Map.of()))) + when(investmentRestAssetUniverseService.createAsset(req, Map.of())) .thenReturn(Mono.just(created)); // Act & Assert @@ -247,7 +247,7 @@ void getOrCreateAsset_assetNotFound_createCalledAndReturned() { .expectNext(created) .verifyComplete(); - verify(investmentRestAssetUniverseService).createAsset(eq(req), eq(Map.of())); + verify(investmentRestAssetUniverseService).createAsset(req, Map.of()); verify(investmentRestAssetUniverseService, never()) .patchAsset( any(com.backbase.investment.api.service.v1.model.Asset.class), @@ -263,7 +263,7 @@ void getOrCreateAsset_nonNotFoundError_propagated() { when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) .thenReturn(Mono.error(new RuntimeException("API error"))); - when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // Act & Assert StepVerifier.create(service.getOrCreateAsset(req, null)) @@ -286,7 +286,7 @@ void getOrCreateAsset_notFoundAndCreateFails_errorPropagated() { when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) .thenReturn(Mono.error(notFound())); - when(investmentRestAssetUniverseService.createAsset(eq(req), isNull())) + when(investmentRestAssetUniverseService.createAsset(req, null)) .thenReturn(Mono.error(new RuntimeException("create failed"))); // Act & Assert @@ -303,7 +303,7 @@ void getOrCreateAsset_notFoundAndCreateReturnsEmpty_completesEmpty() { when(assetUniverseApi.getAsset(anyString(), isNull(), isNull(), isNull())) .thenReturn(Mono.error(notFound())); - when(investmentRestAssetUniverseService.createAsset(eq(req), isNull())) + when(investmentRestAssetUniverseService.createAsset(req, null)) .thenReturn(Mono.empty()); // Act & Assert @@ -352,7 +352,7 @@ void upsertMarketSpecialDay_matchingExists_updateCalledAndReturned() { when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); - when(assetUniverseApi.updateMarketSpecialDay(eq(existingUuid.toString()), eq(request))) + when(assetUniverseApi.updateMarketSpecialDay(existingUuid.toString(), request)) .thenReturn(Mono.just(updated)); when(assetUniverseApi.createMarketSpecialDay(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback @@ -361,7 +361,7 @@ void upsertMarketSpecialDay_matchingExists_updateCalledAndReturned() { .expectNext(updated) .verifyComplete(); - verify(assetUniverseApi).updateMarketSpecialDay(eq(existingUuid.toString()), eq(request)); + verify(assetUniverseApi).updateMarketSpecialDay(existingUuid.toString(), request); verify(assetUniverseApi, never()).createMarketSpecialDay(any()); } @@ -420,7 +420,7 @@ void upsertMarketSpecialDay_updateFails_errorPropagated() { when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); - when(assetUniverseApi.updateMarketSpecialDay(eq(existingUuid.toString()), eq(request))) + when(assetUniverseApi.updateMarketSpecialDay(existingUuid.toString(), request)) .thenReturn(Mono.error(new RuntimeException("update failed"))); when(assetUniverseApi.createMarketSpecialDay(any())).thenReturn(Mono.empty()); // switchIfEmpty fallback @@ -496,16 +496,16 @@ void upsertAssetCategory_matchingCategoryExists_patchCalledAndReturned() { .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); - when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + when(investmentRestAssetUniverseService.patchAssetCategory(existingUuid, entry, null)) .thenReturn(Mono.just(patchedCategory)); - when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback + when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // Act & Assert StepVerifier.create(service.upsertAssetCategory(entry)) .expectNextMatches(result -> existingUuid.equals(result.getUuid())) .verifyComplete(); - verify(investmentRestAssetUniverseService).patchAssetCategory(eq(existingUuid), eq(entry), isNull()); + verify(investmentRestAssetUniverseService).patchAssetCategory(existingUuid, entry, null); verify(investmentRestAssetUniverseService, never()).createAssetCategory(any(), any()); } @@ -523,7 +523,7 @@ void upsertAssetCategory_noMatchingCategory_createCalledAndReturned() { UUID newUuid = UUID.randomUUID(); AssetCategory created = buildSyncAssetCategory(newUuid); - when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + when(investmentRestAssetUniverseService.createAssetCategory(entry, null)) .thenReturn(Mono.just(created)); // Act & Assert @@ -531,7 +531,7 @@ void upsertAssetCategory_noMatchingCategory_createCalledAndReturned() { .expectNextMatches(result -> newUuid.equals(result.getUuid())) .verifyComplete(); - verify(investmentRestAssetUniverseService).createAssetCategory(eq(entry), isNull()); + verify(investmentRestAssetUniverseService).createAssetCategory(entry, null); verify(investmentRestAssetUniverseService, never()).patchAssetCategory(any(), any(), any()); } @@ -546,7 +546,7 @@ void upsertAssetCategory_emptyResultsList_createCalledAndReturned() { UUID newUuid = UUID.randomUUID(); AssetCategory created = buildSyncAssetCategory(newUuid); - when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + when(investmentRestAssetUniverseService.createAssetCategory(entry, null)) .thenReturn(Mono.just(created)); // Act & Assert @@ -554,7 +554,7 @@ void upsertAssetCategory_emptyResultsList_createCalledAndReturned() { .expectNextMatches(result -> newUuid.equals(result.getUuid())) .verifyComplete(); - verify(investmentRestAssetUniverseService).createAssetCategory(eq(entry), isNull()); + verify(investmentRestAssetUniverseService).createAssetCategory(entry, null); } @Test @@ -568,9 +568,9 @@ void upsertAssetCategory_patchFails_errorSwallowedReturnsEmpty() { buildApiAssetCategory(existingUuid, "EQUITY"); when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); - when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + when(investmentRestAssetUniverseService.patchAssetCategory(existingUuid, entry, null)) .thenReturn(Mono.error(new RuntimeException("patch failed"))); - when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // switchIfEmpty fallback (not actually called on error) + when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty()); // Act & Assert — onErrorResume returns Mono.empty() StepVerifier.create(service.upsertAssetCategory(entry)) @@ -585,7 +585,7 @@ void upsertAssetCategory_createFails_errorSwallowedReturnsEmpty() { when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) .thenReturn(Mono.just(buildAssetCategoryPage(List.of()))); - when(investmentRestAssetUniverseService.createAssetCategory(eq(entry), isNull())) + when(investmentRestAssetUniverseService.createAssetCategory(entry, null)) .thenReturn(Mono.error(new RuntimeException("create failed"))); // Act & Assert — onErrorResume returns Mono.empty() @@ -606,7 +606,7 @@ void upsertAssetCategory_successfulPatch_entryUuidUpdated() { .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); - when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), isNull())) + when(investmentRestAssetUniverseService.patchAssetCategory(existingUuid, entry, null)) .thenReturn(Mono.just(patchedCategory)); // Act & Assert @@ -661,11 +661,11 @@ void upsertAssetCategoryType_matchingExists_updateCalledAndReturned() { AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); AssetCategoryType updated = buildAssetCategoryType(existingUuid, "SECTOR", "Sector Updated"); - when(assetUniverseApi.updateAssetCategoryType(eq(existingUuid.toString()), eq(request))) + when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), request)) .thenReturn(Mono.just(updated)); // Act & Assert @@ -673,7 +673,7 @@ void upsertAssetCategoryType_matchingExists_updateCalledAndReturned() { .expectNextMatches(result -> "SECTOR".equals(result.getCode())) .verifyComplete(); - verify(assetUniverseApi).updateAssetCategoryType(eq(existingUuid.toString()), eq(request)); + verify(assetUniverseApi).updateAssetCategoryType(existingUuid.toString(), request); verify(assetUniverseApi, never()).createAssetCategoryType(any()); } @@ -685,7 +685,7 @@ void upsertAssetCategoryType_nullResultsList_createCalledAndReturned() { PaginatedAssetCategoryTypeList page = new PaginatedAssetCategoryTypeList(); page.setResults(null); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(page)); AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); @@ -706,7 +706,7 @@ void upsertAssetCategoryType_emptyResultsList_createCalledAndReturned() { // Arrange AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of()))); AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); @@ -729,7 +729,7 @@ void upsertAssetCategoryType_noMatchingCode_createCalledAndReturned() { // Different code in the results AssetCategoryType other = buildAssetCategoryType(UUID.randomUUID(), "INDUSTRY", "Industry"); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(other)))); AssetCategoryType created = buildAssetCategoryType(UUID.randomUUID(), "SECTOR", "Sector"); @@ -752,9 +752,9 @@ void upsertAssetCategoryType_updateFails_errorSwallowedReturnsEmpty() { AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); - when(assetUniverseApi.updateAssetCategoryType(eq(existingUuid.toString()), eq(request))) + when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), request)) .thenReturn(Mono.error(new RuntimeException("update failed"))); // switchIfEmpty path stubbed so the mono completes rather than hanging @@ -771,7 +771,7 @@ void upsertAssetCategoryType_createFails_errorPropagated() { // Arrange AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes(eq("SECTOR"), eq(100), eq("Sector"), eq(0))) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of()))); when(assetUniverseApi.createAssetCategoryType(request)) .thenReturn(Mono.error(new RuntimeException("create failed"))); @@ -971,4 +971,7 @@ private PaginatedAssetCategoryTypeList buildAssetCategoryTypePage(List e instanceof IllegalStateException) + .expectErrorMatches(IllegalStateException.class::isInstance) .verify(); } } @@ -398,7 +405,7 @@ void upsertPortfolioTradingAccounts_singleFailure_doesNotStopBatch() { // Act & Assert StepVerifier.create(service.upsertPortfolioTradingAccounts(input)) .expectNextMatches(list -> list.size() == 1 - && createdUuid.equals(list.get(0).getUuid())) + && createdUuid.equals(list.getFirst().getUuid())) .verifyComplete(); } @@ -649,7 +656,7 @@ void upsertInvestmentPortfolios_multipleExistingPortfolios_returnsError() { // Act & Assert StepVerifier.create(service.upsertInvestmentPortfolios(arrangement, Map.of())) - .expectErrorMatches(e -> e instanceof IllegalStateException) + .expectErrorMatches(IllegalStateException.class::isInstance) .verify(); } @@ -657,7 +664,7 @@ void upsertInvestmentPortfolios_multipleExistingPortfolios_returnsError() { @DisplayName("null arrangement — throws NullPointerException immediately") void upsertInvestmentPortfolios_nullArrangement_throwsNullPointerException() { assertThrows(NullPointerException.class, - () -> service.upsertInvestmentPortfolios(null, Map.of())); + () -> service.upsertInvestmentPortfolios(null, Map.of()).subscribe()); } } @@ -912,7 +919,7 @@ void upsertInvestmentProducts_unknownProductType_returnsError() { // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) - .expectErrorMatches(e -> e instanceof IllegalStateException) + .expectErrorMatches(IllegalStateException.class::isInstance) .verify(); } @@ -947,7 +954,7 @@ void upsertInvestmentProducts_selfTradingType_existingProduct_patchesAndReturns( // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) .expectNextMatches(products -> products.size() == 1 - && productUuid.equals(products.get(0).getUuid())) + && productUuid.equals(products.getFirst().getUuid())) .verifyComplete(); verify(productsApi).patchPortfolioProduct( @@ -981,7 +988,7 @@ void upsertInvestmentProducts_selfTradingType_noExistingProduct_createsNew() { // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) .expectNextMatches(products -> products.size() == 1 - && newProductUuid.equals(products.get(0).getUuid())) + && newProductUuid.equals(products.getFirst().getUuid())) .verifyComplete(); verify(productsApi).createPortfolioProduct(any(), any(), isNull(), isNull()); @@ -1020,7 +1027,7 @@ void upsertInvestmentProducts_roboType_withModelPortfolio_createsProductWithMode // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) .expectNextMatches(products -> products.size() == 1 - && newProductUuid.equals(products.get(0).getUuid())) + && newProductUuid.equals(products.getFirst().getUuid())) .verifyComplete(); verify(productsApi).createPortfolioProduct(any(), any(), isNull(), isNull()); @@ -1039,7 +1046,7 @@ void upsertInvestmentProducts_roboType_noModelPortfolio_returnsError() { // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) - .expectErrorMatches(e -> e instanceof IllegalStateException) + .expectErrorMatches(IllegalStateException.class::isInstance) .verify(); } @@ -1103,7 +1110,7 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist // Act & Assert StepVerifier.create(service.upsertInvestmentProducts(investmentData, List.of(arrangement))) .expectNextMatches(products -> products.size() == 1 - && productUuid.equals(products.get(0).getUuid())) + && productUuid.equals(products.getFirst().getUuid())) .verifyComplete(); verify(productsApi, never()).createPortfolioProduct(any(), any(), any(), any()); @@ -1114,7 +1121,7 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist void upsertInvestmentProducts_nullArrangements_throwsNullPointerException() { InvestmentData investmentData = Mockito.mock(InvestmentData.class); assertThrows(NullPointerException.class, - () -> service.upsertInvestmentProducts(investmentData, null)); + () -> service.upsertInvestmentProducts(investmentData, null).subscribe()); } } From 9e1404d256bd5164c01a1819720125e5c3d13477 Mon Sep 17 00:00:00 2001 From: pawana_backbase Date: Mon, 16 Mar 2026 18:48:47 +0530 Subject: [PATCH 5/5] Minor fix --- .../service/InvestmentPortfolioService.java | 9 ++++++--- .../service/InvestmentPortfolioServiceTest.java | 11 ++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) 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 80511ac91..64b66f46a 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 @@ -34,7 +34,6 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; -import java.util.function.Predicate; import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -112,7 +111,9 @@ public Mono> upsertPortfolios(List in */ public Mono> upsertInvestmentProducts(InvestmentData investmentData, List investmentArrangements) { - Objects.requireNonNull(investmentArrangements, "InvestmentArrangement must not be null"); + if (investmentArrangements == null) { + return Mono.error(new NullPointerException("InvestmentArrangement must not be null")); + } return Flux.fromIterable(investmentArrangements) .flatMap(investmentArrangement -> { @@ -235,7 +236,9 @@ private static List findModelUuid(InvestmentData investmentData, */ public Mono upsertInvestmentPortfolios(InvestmentArrangement investmentArrangement, Map> clientsByLeExternalId) { - Objects.requireNonNull(investmentArrangement, "InvestmentArrangement must not be null"); + if (investmentArrangement == null) { + return Mono.error(new NullPointerException("InvestmentArrangement must not be null")); + } String externalId = investmentArrangement.getExternalId(); String arrangementName = investmentArrangement.getName(); 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 d9298a338..820538460 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,6 +1,5 @@ package com.backbase.stream.investment.service; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -663,8 +662,9 @@ void upsertInvestmentPortfolios_multipleExistingPortfolios_returnsError() { @Test @DisplayName("null arrangement — throws NullPointerException immediately") void upsertInvestmentPortfolios_nullArrangement_throwsNullPointerException() { - assertThrows(NullPointerException.class, - () -> service.upsertInvestmentPortfolios(null, Map.of()).subscribe()); + StepVerifier.create(service.upsertInvestmentPortfolios(null, Map.of())) + .expectError(NullPointerException.class) + .verify(); } } @@ -1120,8 +1120,9 @@ void upsertInvestmentProducts_patchFails_withWebClientException_fallsBackToExist @DisplayName("null arrangements list — throws NullPointerException immediately") void upsertInvestmentProducts_nullArrangements_throwsNullPointerException() { InvestmentData investmentData = Mockito.mock(InvestmentData.class); - assertThrows(NullPointerException.class, - () -> service.upsertInvestmentProducts(investmentData, null).subscribe()); + StepVerifier.create(service.upsertInvestmentProducts(investmentData, null)) + .expectError(NullPointerException.class) + .verify(); } }