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..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 @@ -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; @@ -17,18 +16,19 @@ import reactor.core.publisher.Mono; /** - * Saga orchestrating the complete investment client ingestion workflow. + * Saga orchestrating the complete investment content ingestion workflow. * - *
This saga implements a multi-step process for ingesting investment data: + *
This saga implements a multi-step process for ingesting investment content data: *
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. + * 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: *
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: + *
Test strategy: + *
Mocked dependencies: *
Verifies that: - *
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 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 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:
- * 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 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:
- * 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 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:
- * 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 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:
- * Verifies that:
- * 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 This class verifies the complete orchestration logic of the saga, which drives
+ * the investment content ingestion pipeline through the following stages:
+ * Test strategy:
+ * Mocked dependencies:
+ * 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 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 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 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 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/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:
+ * Covers:
+ * 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:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * 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:
+ * Covers:
+ * 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:
+ * Covers:
+ * Covers:
+ * This class verifies the complete orchestration logic of the service, which drives
+ * the model portfolio ingestion pipeline through the following stages:
+ * Test strategy:
+ * Mocked dependencies:
+ * 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:
+ * Covers:
+ * 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:
+ * Covers:
+ * 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:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ * Covers:
+ *
- *
+ *
- *
+ *
- *
+ *
- *
+ * @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
- *
+ * @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
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+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.
+ *
+ *
+ *
+ */
+@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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
*/
+@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();
- }
-}
\ No newline at end of file
+ assetUniverseApi = mock(AssetUniverseApi.class);
+ investmentRestAssetUniverseService = mock(InvestmentRestAssetUniverseService.class);
+ service = new InvestmentAssetUniverseService(assetUniverseApi, investmentRestAssetUniverseService);
+ }
+
+ // =========================================================================
+ // upsertMarket
+ // =========================================================================
+
+ /**
+ * Tests for {@link InvestmentAssetUniverseService#upsertMarket(MarketRequest)}.
+ *
+ *
+ *
+ */
+ @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("US", 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("US", 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("US", 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}.
+ *
+ *
+ *
+ */
+ @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("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());
+
+ // 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("ABC123_market_USD", null, null, null))
+ .thenReturn(Mono.error(notFound()));
+ when(investmentRestAssetUniverseService.createAsset(req, Map.of()))
+ .thenReturn(Mono.just(created));
+
+ // Act & Assert
+ StepVerifier.create(service.getOrCreateAsset(req, Map.of()))
+ .expectNext(created)
+ .verifyComplete();
+
+ verify(investmentRestAssetUniverseService).createAsset(req, 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());
+
+ // 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(req, null))
+ .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(req, null))
+ .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)}.
+ *
+ *
+ *
+ */
+ @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(existingUuid.toString(), 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(existingUuid.toString(), 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(existingUuid.toString(), 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)}.
+ *
+ *
+ *
+ */
+ @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(existingUuid, entry, null))
+ .thenReturn(Mono.just(patchedCategory));
+ 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(existingUuid, entry, null);
+ 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(entry, null))
+ .thenReturn(Mono.just(created));
+
+ // Act & Assert
+ StepVerifier.create(service.upsertAssetCategory(entry))
+ .expectNextMatches(result -> newUuid.equals(result.getUuid()))
+ .verifyComplete();
+
+ verify(investmentRestAssetUniverseService).createAssetCategory(entry, null);
+ 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(entry, null))
+ .thenReturn(Mono.just(created));
+
+ // Act & Assert
+ StepVerifier.create(service.upsertAssetCategory(entry))
+ .expectNextMatches(result -> newUuid.equals(result.getUuid()))
+ .verifyComplete();
+
+ verify(investmentRestAssetUniverseService).createAssetCategory(entry, null);
+ }
+
+ @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(existingUuid, entry, null))
+ .thenReturn(Mono.error(new RuntimeException("patch failed")));
+ when(investmentRestAssetUniverseService.createAssetCategory(any(), any())).thenReturn(Mono.empty());
+
+ // 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(entry, null))
+ .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(existingUuid, entry, null))
+ .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)}.
+ *
+ *
+ *
+ */
+ @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("SECTOR", 100, "Sector", 0))
+ .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType))));
+
+ AssetCategoryType updated = buildAssetCategoryType(existingUuid, "SECTOR", "Sector Updated");
+ when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), request))
+ .thenReturn(Mono.just(updated));
+
+ // Act & Assert
+ StepVerifier.create(service.upsertAssetCategoryType(request))
+ .expectNextMatches(result -> "SECTOR".equals(result.getCode()))
+ .verifyComplete();
+
+ verify(assetUniverseApi).updateAssetCategoryType(existingUuid.toString(), 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("SECTOR", 100, "Sector", 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("SECTOR", 100, "Sector", 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("SECTOR", 100, "Sector", 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("SECTOR", 100, "Sector", 0))
+ .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType))));
+ when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), 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("SECTOR", 100, "Sector", 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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
+ */
+@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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
*/
+@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)}.
+ *
+ *
+ *
+ */
+ @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()}.
+ *
+ *
+ *
+ */
+ @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
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+@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
+ *
+ */
+@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)}.
+ *
+ *
+ *
+ */
+ @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)}.
+ *
+ *
+ *
+ */
+ @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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
+ */
+@DisplayName("InvestmentPortfolioService")
class InvestmentPortfolioServiceTest {
private InvestmentProductsApi productsApi;
@@ -49,305 +84,1055 @@ 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)}.
+ *
+ *
+ *
+ */
+ @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(IllegalStateException.class::isInstance)
+ .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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
+ */
+ @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)}.
+ *
+ *
+ *
+ */
+ @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
+ *
+ */
+ @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
+ *
+ */
+ @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
+ *
+ */
+ @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(IllegalStateException.class::isInstance)
+ .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.getFirst().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.getFirst().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.getFirst().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(IllegalStateException.class::isInstance)
+ .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.getFirst().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);
+ StepVerifier.create(service.upsertInvestmentProducts(investmentData, null))
+ .expectError(NullPointerException.class)
+ .verify();
+ }
}
- // -----------------------------------------------------------------------
- // 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 +1142,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 +1205,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/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;
- }
- }*/
-}
-
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