From ba73d89222ad0e6ceb2a201c3f3376d0fc4e8401 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 13 May 2026 09:33:11 +0300 Subject: [PATCH 01/11] fix(TAR-738): upgrade investment-service-api from 1.4.1 to 1.6.0 - Bump investment-service-api.version to 1.6.0 in investment-core/pom.xml - Fix InvestmentPortfolioService: update InvestorModelPortfolio (7-arg) and PortfolioProduct (10-arg) constructors; add two trailing nulls to listPortfolioProducts call for new 1.6.0 parameters - Fix test fixtures: 7-arg InvestorModelPortfolio in AllocationServiceTest; 13-arg listPortfolioProducts stubs across 5 occurrences in ServiceTest - All 429 unit tests pass (mvn clean install -pl stream-investment/investment-core -am) Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ea9260b..3b0f979e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ All notable changes to this project will be documented in this file. - Added dependency validation to stream-compositions services pom.xml to fix product validation issues for arrangements with additional properties. ## [10.1.0](https://github.com/Backbase/stream-services/compare/9.17.0...10.1.0) +## [10.2.0] +### Changed +- Upgrade `investment-service-api` version from `1.4.1` to `1.6.0`; regenerate API clients and fix all compilation errors in production and test sources. + +## [10.1.0] ### Changed - Align Spring Boot and Spring Cloud versions with Service SDK 21.0.1 managed stack. - Remove local Spring metadata plugin and Azure Service Bus version overrides in favor of the Service SDK 21.0.1 managed dependency chain. From 89c3729869d245f302f1475621e65801bfb157a5 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Tue, 26 May 2026 12:57:48 +0300 Subject: [PATCH 02/11] TAR-797: BE: use new portfolio product properties and deprecate an old one define all Product portfolio properties --- .../backbase/stream/investment/saga/InvestmentSagaTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index b5e89943d..9a6a811a6 100644 --- 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 @@ -97,8 +97,7 @@ void setUp() { investmentModelPortfolioService, investmentPortfolioProductService, asyncTaskService, - configurationProperties, - null + configurationProperties ); } From 88d72c0a8febe9e9875b9eb31b9110ba0287f18d Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Fri, 29 May 2026 09:21:39 +0300 Subject: [PATCH 03/11] TAR-797: BE: use new portfolio product properties and deprecate an old one define new product setup insert & update --- .../backbase/stream/investment/saga/InvestmentSagaTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 9a6a811a6..b5e89943d 100644 --- 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 @@ -97,7 +97,8 @@ void setUp() { investmentModelPortfolioService, investmentPortfolioProductService, asyncTaskService, - configurationProperties + configurationProperties, + null ); } From e9f372859f87cd658aded8c4c1bd3865a6674530 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Fri, 29 May 2026 17:26:30 +0300 Subject: [PATCH 04/11] TAR-797: BE: use new portfolio product properties and deprecate an old one fix CHANGELOG.md --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0f979e5..39407034c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,11 @@ All notable changes to this project will be documented in this file. ### Changed - Upgrade `investment-service-api` version from `1.4.1` to `1.6.0`; regenerate API clients and fix all compilation errors in production and test sources. + ## [10.1.1](https://github.com/Backbase/stream-services/compare/10.1.0...10.1.1) ### Changed - Added dependency validation to stream-compositions services pom.xml to fix product validation issues for arrangements with additional properties. -## [10.1.0](https://github.com/Backbase/stream-services/compare/9.17.0...10.1.0) -## [10.2.0] -### Changed -- Upgrade `investment-service-api` version from `1.4.1` to `1.6.0`; regenerate API clients and fix all compilation errors in production and test sources. - ## [10.1.0] ### Changed - Align Spring Boot and Spring Cloud versions with Service SDK 21.0.1 managed stack. From 88ff6581b6ba384aa1679e447eb2f5293f655c17 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 3 Jun 2026 11:42:22 +0300 Subject: [PATCH 05/11] TAR-797: BE: use new portfolio product properties and deprecate an old one ingest product portfolio image icons --- .../INVESTMENT_API_ENDPOINTS_USED.md | 321 +++++++++++------- .../investment/saga/InvestmentSaga.java | 5 +- .../InvestmentModelPortfolioService.java | 25 +- .../InvestmentPortfolioProductService.java | 92 +---- ...InvestmentRestProductPortfolioService.java | 13 +- 5 files changed, 240 insertions(+), 216 deletions(-) diff --git a/stream-investment/INVESTMENT_API_ENDPOINTS_USED.md b/stream-investment/INVESTMENT_API_ENDPOINTS_USED.md index 92de2d673..db86e3134 100644 --- a/stream-investment/INVESTMENT_API_ENDPOINTS_USED.md +++ b/stream-investment/INVESTMENT_API_ENDPOINTS_USED.md @@ -2,146 +2,221 @@ This document lists all Investment Service API endpoints that are actively used in the `investment-core` package. -## Asset Universe API (AssetUniverseApi) +Generated API clients live under `com.backbase.investment.api.service.v1` (reactive WebClient) and `com.backbase.investment.api.service.sync.v1` (blocking RestTemplate for multipart). All paths below are under `/service-api/v2/`. + +--- + +## Asset Universe API (AssetUniverseApi + InvestmentRestAssetUniverseService) ### Markets -- `GET /service-api/v2/asset/markets/{code}` - Get market by code -- `POST /service-api/v2/asset/markets` - Create market -- `PUT /service-api/v2/asset/markets/{code}` - Update market +- `GET /service-api/v2/asset/markets/{code}/` - Get market by code +- `POST /service-api/v2/asset/markets/` - Create market +- `PUT /service-api/v2/asset/markets/{code}/` - Update market ### Assets -- `GET /service-api/v2/asset/assets/{assetIdentifier}` - Get asset by identifier (ISIN_Market_Currency) -- `POST /service-api/v2/asset/assets` - Create asset (via CustomIntegrationApiService) -- `PATCH /service-api/v2/asset/assets/{uuid}` - Patch asset (via InvestmentRestAssetUniverseService.patchAsset - for logo upload) -- `GET /service-api/v2/asset/assets` - List assets with response spec (for intraday prices) +- `GET /service-api/v2/asset/assets/{asset_identifier}/` - Get asset by identifier (ISIN_Market_Currency) +- `POST /service-api/v2/asset/assets/` - Create asset with optional logo (`InvestmentRestAssetUniverseService`, multipart/form-data) +- `PATCH /service-api/v2/asset/assets/{asset_identifier}/` - Patch asset with optional logo (`InvestmentRestAssetUniverseService`, multipart/form-data) +- `GET /service-api/v2/asset/assets/` - List assets with expand (`market`, `latest_price`) for intraday price generation ### Asset Categories -- `GET /service-api/v2/asset/asset-categories` - List asset categories -- `POST /service-api/v2/asset/asset-categories` - Create asset category -- `PUT /service-api/v2/asset/asset-categories/{uuid}` - Update asset category -- `PATCH /service-api/v2/asset/asset-categories/{uuid}/` - Partial update asset category (for image upload) +- `GET /service-api/v2/asset/asset-categories/` - List asset categories +- `POST /service-api/v2/asset/asset-categories/` - Create asset category with optional image (`InvestmentRestAssetUniverseService`, multipart/form-data) +- `PATCH /service-api/v2/asset/asset-categories/{uuid}/` - Partial update asset category with optional image (`InvestmentRestAssetUniverseService`, multipart/form-data) ### Asset Category Types -- `GET /service-api/v2/asset/asset-category-types` - List asset category types -- `POST /service-api/v2/asset/asset-category-types` - Create asset category type -- `PUT /service-api/v2/asset/asset-category-types/{uuid}` - Update asset category type +- `GET /service-api/v2/asset/asset-category-types/` - List asset category types +- `POST /service-api/v2/asset/asset-category-types/` - Create asset category type +- `PUT /service-api/v2/asset/asset-category-types/{uuid}/` - Update asset category type ### Market Special Days -- `GET /service-api/v2/asset/market-special-days` - List market special days -- `POST /service-api/v2/asset/market-special-days` - Create market special day -- `PUT /service-api/v2/asset/market-special-days/{uuid}` - Update market special day +- `GET /service-api/v2/asset/market-special-days/` - List market special days +- `POST /service-api/v2/asset/market-special-days/` - Create market special day +- `PUT /service-api/v2/asset/market-special-days/{uuid}/` - Update market special day ### Asset Prices -- `GET /service-api/v2/asset/assets/{assetIdentifier}/prices/close` - List asset close prices -- `POST /service-api/v2/asset/assets/{assetIdentifier}/prices/close` - Create asset close prices (batch) -- `POST /service-api/v2/asset/assets/{assetIdentifier}/prices/intraday` - Create intraday asset prices +- `GET /service-api/v2/asset/prices-close/` - List asset close prices +- `POST /service-api/v2/asset/prices-close/bulk-create/` - Bulk create asset close prices (returns async bulk group results) +- `POST /service-api/v2/asset/prices-intraday/bulk-create/` - Bulk create intraday asset prices (returns async bulk group results) + +--- ## Client API (ClientApi) -- `POST /service-api/v2/client/clients` - Create client -- `GET /service-api/v2/client/clients` - List clients (with filters: internalUserId) -- `GET /service-api/v2/client/clients/{uuid}` - Get client by UUID -- `PATCH /service-api/v2/client/clients/{uuid}` - Patch client (partial update) -- `PUT /service-api/v2/client/clients/{uuid}` - Update client (full update) +- `POST /service-api/v2/account/clients/` - Create client +- `GET /service-api/v2/account/clients/` - List clients (with filters: internalUserId) +- `GET /service-api/v2/account/clients/{uuid}/` - Get client by UUID +- `PATCH /service-api/v2/account/clients/{uuid}/` - Patch client (partial update; used in upsert flow) +- `PUT /service-api/v2/account/clients/{uuid}/` - Update client (full update; public API on `InvestmentClientService`) -## Investment Products API (InvestmentProductsApi) +--- -- `GET /service-api/v2/investment-product/portfolio-products` - List portfolio products -- `POST /service-api/v2/investment-product/portfolio-products` - Create portfolio product -- `PATCH /service-api/v2/investment-product/portfolio-products/{uuid}` - Patch portfolio product +## Investment Products API (InvestmentProductsApi + InvestmentRestProductPortfolioService) + +- `GET /service-api/v2/products/portfolio/` - List portfolio products +- `POST /service-api/v2/products/portfolio/` - Create portfolio product (JSON via `InvestmentProductsApi`) +- `PUT /service-api/v2/products/portfolio/{uuid}/` - Update portfolio product (JSON via `updatePortfolioProduct`) +- `PATCH /service-api/v2/products/portfolio/{uuid}/` - Patch portfolio product with optional image (`InvestmentRestProductPortfolioService`, multipart/form-data when `ingestImages` is enabled) + +--- ## Portfolio API (PortfolioApi) -- `GET /service-api/v2/portfolio/portfolios` - List portfolios (with filters: externalId) -- `POST /service-api/v2/portfolio/portfolios` - Create portfolio -- `PATCH /service-api/v2/portfolio/portfolios/{uuid}` - Patch portfolio +- `GET /service-api/v2/portfolios/` - List portfolios (with filters: externalId) +- `POST /service-api/v2/portfolios/` - Create portfolio +- `PATCH /service-api/v2/portfolios/{uuid}/` - Patch portfolio + +--- -## Financial Advice API (FinancialAdviceApi) +## Portfolio Trading Accounts API (PortfolioTradingAccountsApi) + +- `GET /service-api/v2/portfolio-trading-accounts/` - List portfolio trading accounts (filter: externalAccountId) +- `POST /service-api/v2/portfolio-trading-accounts/` - Create portfolio trading account +- `PATCH /service-api/v2/portfolio-trading-accounts/{uuid}/` - Patch portfolio trading account + +--- + +## Financial Advice API (FinancialAdviceApi + InvestmentRestModelPortfolioService) ### Model Portfolios -- `GET /service-api/v2/financial-advice/model-portfolios` - List model portfolios (with filters: name, riskLevel) -- `POST /service-api/v2/financial-advice/model-portfolios` - Create model portfolio (via CustomIntegrationApiService) -- `PATCH /service-api/v2/financial-advice/model-portfolios/{uuid}` - Patch model portfolio (via CustomIntegrationApiService) +- `GET /service-api/v2/advice-engines/model-portfolio/model_portfolios/` - List model portfolios (with filters: name, riskLevel) +- `POST /service-api/v2/advice-engines/model-portfolio/model_portfolios/` - Create model portfolio with optional image (`InvestmentRestModelPortfolioService`, multipart/form-data) +- `PUT /service-api/v2/advice-engines/model-portfolio/model_portfolios/{uuid}/` - Update model portfolio with optional image (`InvestmentRestModelPortfolioService`, multipart/form-data; used for upsert when image/data changed) + +--- ## Allocations API (AllocationsApi) -- `GET /service-api/v2/portfolio/portfolios/{portfolioUuid}/allocations` - List portfolio allocations -- `DELETE /service-api/v2/portfolio/portfolios/{portfolioUuid}/allocations/{valuationDate}` - Delete portfolio allocation by valuation date -- `POST /service-api/v2/portfolio/portfolios/{portfolioUuid}/allocations` - Create portfolio allocation (via CustomIntegrationApiService) +- `GET /service-api/v2/portfolios/{portfolio_uuid}/allocations/` - List portfolio allocations +- `DELETE /service-api/v2/portfolios/{portfolio_uuid}/allocations/{valuation_date}/` - Delete portfolio allocation by valuation date +- `POST /service-api/v2/portfolios/{portfolio_uuid}/allocations/` - Create portfolio allocation + +--- ## Payments API (PaymentsApi) -- `GET /service-api/v2/payment/deposits` - List deposits (with filters: portfolio UUID) -- `POST /service-api/v2/payment/deposits` - Create deposit +- `GET /service-api/v2/deposits/` - List deposits (with filters: portfolio UUID) +- `POST /service-api/v2/deposits/` - Create deposit -## Investment API (InvestmentApi) +--- -- `GET /service-api/v2/investment/orders` - List orders (with filters: assetKey) -- `POST /service-api/v2/investment/orders` - Create order +## Investment / Broker API (InvestmentApi) -## Content API (ContentApi) - News/Content Management +- `GET /service-api/v2/broker/orders/` - List orders (with filters: assetKey, portfolio) +- `POST /service-api/v2/broker/orders/` - Create order -- `GET /service-api/v2/content/entries` - List content entries -- `POST /service-api/v2/content/entries` - Create content entry -- `PATCH /service-api/v2/content/entries/{uuid}/` - Patch content entry (for thumbnail upload) +--- -## Async Bulk Groups API (AsyncBulkGroupsApi) +## Content API (ContentApi) - News, Documents, Tags + +Uses blocking `com.backbase.investment.api.service.sync.v1.ContentApi` for tag/entry CRUD and `ApiClient.invokeAPI` for multipart document/entry uploads. + +### Content Entry Tags (market news) +- `GET /service-api/v2/content/entry-tags/` - List content entry tags +- `POST /service-api/v2/content/entry-tags/` - Create content entry tag +- `PATCH /service-api/v2/content/entry-tags/{code}/` - Patch content entry tag + +### Content Entries (market news) +- `GET /service-api/v2/content/entries/` - List content entries +- `POST /service-api/v2/content/entries/` - Create content entry +- `PATCH /service-api/v2/content/entries/{uuid}/` - Patch content entry with optional thumbnail (`InvestmentRestNewsContentService`, multipart/form-data) + +### Content Document Tags +- `GET /service-api/v2/content/document-tags/` - List document tags +- `POST /service-api/v2/content/document-tags/` - Create document tag +- `PATCH /service-api/v2/content/document-tags/{code}/` - Patch document tag -- `GET /service-api/v2/async/bulk-groups/{uuid}` - Get bulk group status +### Content Documents +- `GET /service-api/v2/content/documents/` - List content documents +- `POST /service-api/v2/content/documents/` - Create content document with file (`InvestmentRestDocumentContentService`, multipart/form-data) +- `PATCH /service-api/v2/content/documents/{uuid}/` - Patch content document with file (`InvestmentRestDocumentContentService`, multipart/form-data) --- -## Integration API (CustomIntegrationApiService) +## Currency API (CurrencyApi) -**Note:** This service is deprecated since 8.6.0 and uses integration-api endpoints instead of service-api. +- `GET /service-api/v2/currencies/` - List currencies +- `POST /service-api/v2/currencies/` - Create currency +- `PUT /service-api/v2/currencies/{code}/` - Update currency -### Assets -- `POST /integration-api/v2/asset/assets/` - Create asset (custom implementation) +--- -### Model Portfolios -- `POST /integration-api/v2/advice-engines/model-portfolio/model_portfolios/` - Create model portfolio (with optional image upload via multipart/form-data) -- `PATCH /integration-api/v2/advice-engines/model-portfolio/model_portfolios/{uuid}/` - Patch model portfolio (with optional image upload via multipart/form-data) +## Risk Assessment API (RiskAssessmentApi) + +### Client Risk Assessments +- `GET /service-api/v2/account/clients/{client_uuid}/risk-assessments/` - List risk assessments for client +- `POST /service-api/v2/account/clients/{client_uuid}/risk-assessments/` - Create risk assessment +- `PATCH /service-api/v2/account/clients/{client_uuid}/risk-assessments/{uuid}/` - Patch risk assessment + +### Risk Questionnaire +- `GET /service-api/v2/risk/questions/` - List risk questions +- `POST /service-api/v2/risk/questions/` - Create risk question +- `PATCH /service-api/v2/risk/questions/{uuid}/` - Patch risk question +- `GET /service-api/v2/risk/choices/` - List risk choices +- `POST /service-api/v2/risk/choices/` - Create risk choice +- `PATCH /service-api/v2/risk/choices/{uuid}/` - Patch risk choice -### Portfolio Allocations -- `POST /integration-api/v2/portfolios/{portfolio_uuid}/allocations/` - Create portfolio allocation +--- + +## Async Bulk Groups API (AsyncBulkGroupsApi) + +- `GET /service-api/v2/bulkgroup/{uuid}/` - Get bulk group status (poll after bulk price creation) --- ## Summary by Service ### InvestmentAssetUniverseService -- Uses: AssetUniverseApi (markets, assets, categories, category types, special days, prices) -- Uses: InvestmentRestAssetUniverseService (multipart uploads for logos) -- Uses: CustomIntegrationApiService (asset creation with custom logic) +- Uses: `AssetUniverseApi` (markets, assets lookup, categories list, category types, special days) +- Uses: `InvestmentRestAssetUniverseService` (multipart create/patch for assets and asset categories with logos/images) + +### InvestmentClientService +- Uses: `ClientApi` (create, list, get, patch, update clients) -### InvestmentClientService -- Uses: ClientApi (create, list, get, patch, update clients) +### InvestmentPortfolioProductService +- Uses: `InvestmentProductsApi` (list, create, update portfolio products) +- Uses: `InvestmentRestProductPortfolioService` (multipart patch when product images are ingested) +- Uses: `InvestmentModelPortfolioService` (model portfolio upsert for product linkage) ### InvestmentPortfolioService -- Uses: InvestmentProductsApi (portfolio products) -- Uses: PortfolioApi (portfolios) -- Uses: PaymentsApi (deposits) +- Uses: `PortfolioApi` (portfolios) +- Uses: `PaymentsApi` (deposits) +- Uses: `PortfolioTradingAccountsApi` (portfolio trading accounts) ### InvestmentModelPortfolioService -- Uses: FinancialAdviceApi (model portfolios) -- Uses: CustomIntegrationApiService (model portfolio creation/patching) +- Uses: `FinancialAdviceApi` (list model portfolios) +- Uses: `InvestmentRestModelPortfolioService` (multipart create/update model portfolios with images) ### InvestmentPortfolioAllocationService -- Uses: AllocationsApi (portfolio allocations) -- Uses: InvestmentApi (orders) -- Uses: AssetUniverseApi (asset prices) +- Uses: `AllocationsApi` (portfolio allocations) +- Uses: `InvestmentApi` (broker orders) +- Uses: `AssetUniverseApi` (list close prices for allocation generation) ### InvestmentAssetPriceService -- Uses: AssetUniverseApi (asset close prices) +- Uses: `AssetUniverseApi` (list close prices, bulk create close prices) ### InvestmentIntradayAssetPriceService -- Uses: AssetUniverseApi (list assets, intraday prices) +- Uses: `AssetUniverseApi` (list assets with expand, bulk create intraday prices) + +### InvestmentCurrencyService +- Uses: `CurrencyApi` (list, create, update currencies) + +### InvestmentRiskAssessmentService +- Uses: `RiskAssessmentApi` (client risk assessments) + +### InvestmentRiskQuestionaryService +- Uses: `RiskAssessmentApi` (risk questions and choices) ### InvestmentRestNewsContentService -- Uses: ContentApi (news/content entries) +- Uses: `ContentApi` (entry tags, content entries) +- Uses: `ApiClient` (multipart thumbnail patch on entries) + +### InvestmentRestDocumentContentService +- Uses: `ContentApi` (document tags, list documents) +- Uses: `ApiClient` (multipart create/patch documents) ### AsyncTaskService -- Uses: AsyncBulkGroupsApi (bulk operation status) +- Uses: `AsyncBulkGroupsApi` (bulk operation status after price ingestion) ### WorkDayService - No external API calls (utility service) @@ -150,54 +225,64 @@ This document lists all Investment Service API endpoints that are actively used ## Notes -1. **Custom Integration API Service**: This is a custom wrapper service (marked as deprecated since 8.6.0) that uses `/integration-api/v2/` endpoints instead of `/service-api/v2/` endpoints. It handles: - - Asset creation via POST `/integration-api/v2/asset/assets/` - - Model portfolio creation/patching with optional image uploads via multipart/form-data - - Portfolio allocation creation +1. **No Integration API usage**: `CustomIntegrationApiService` and `/integration-api/v2/` endpoints were removed. Create/update operations that previously used the integration API now use `/service-api/v2/` via generated clients or RestTemplate `ApiClient` wrappers. + +2. **REST Template Services** (blocking `ApiClient`, `multipart/form-data`): Used where generated reactive clients cannot serialize multipart correctly: + - `InvestmentRestAssetUniverseService` - Asset and asset category logos/images + - `InvestmentRestModelPortfolioService` - Model portfolio images (create POST, update PUT) + - `InvestmentRestProductPortfolioService` - Portfolio product images (patch) + - `InvestmentRestNewsContentService` - Content entry thumbnails + - `InvestmentRestDocumentContentService` - Content document file uploads -2. **REST Template Services**: Two services handle multipart uploads that generated API clients can't handle properly: - - `InvestmentRestAssetUniverseService` - For asset and asset category logo uploads - - `InvestmentRestNewsContentService` - For content entry thumbnail uploads +3. **Bulk price ingestion**: Close and intraday prices are submitted via bulk-create endpoints, which return async bulk group UUIDs polled by `AsyncTaskService`. -3. **Upsert Pattern**: Most services implement an upsert pattern: +4. **Upsert pattern**: Most services implement an upsert pattern: - Try to GET/LIST existing entity - - If found (or 200 OK), PATCH/PUT to update - - If not found (404), POST to create + - If found (or 200 OK), PATCH/PUT to update when data changed + - If not found (404 or empty list), POST to create -4. **Endpoint Patterns**: - - Service API endpoints follow: `/service-api/v2/{domain}/{resource}` - - Integration API endpoints follow: `/integration-api/v2/{domain}/{resource}` (used by CustomIntegrationApiService) - - Multipart upload endpoints use PATCH or POST with `multipart/form-data` content type - - Most list endpoints support pagination and filtering +5. **Endpoint path conventions** (current OpenAPI spec): + - Portfolios: `/service-api/v2/portfolios/` (not `/portfolio/portfolios`) + - Portfolio products: `/service-api/v2/products/portfolio/` (not `/investment-product/portfolio-products`) + - Clients: `/service-api/v2/account/clients/` + - Orders: `/service-api/v2/broker/orders/` + - Deposits: `/service-api/v2/deposits/` + - Async status: `/service-api/v2/bulkgroup/{uuid}/` + - Trailing slashes are used on most resource paths in the generated clients -5. **Error Handling**: All services handle WebClientResponseException with special logic for 404 (Not Found) responses to implement upsert patterns. +6. **Error handling**: Services handle `WebClientResponseException` with special logic for 404 (Not Found) to implement upsert patterns. Retry with backoff is applied for 409/503 on selected asset-universe operations. --- ## Total Endpoint Count -### Service API Endpoints: 42 -- Asset Universe API: 17 endpoints -- Client API: 5 endpoints -- Investment Products API: 3 endpoints -- Portfolio API: 3 endpoints -- Financial Advice API: 3 endpoints -- Allocations API: 3 endpoints -- Content API: 3 endpoints -- Payments API: 2 endpoints -- Investment API: 2 endpoints -- Async Bulk Groups API: 1 endpoint - -### Integration API Endpoints: 4 -- Asset creation: 1 endpoint -- Model portfolios: 2 endpoints -- Portfolio allocations: 1 endpoint - -### Grand Total: 46 unique API endpoints - -By HTTP Method: -- GET: 17 endpoints -- POST: 19 endpoints (15 service-api + 4 integration-api) -- PATCH: 8 endpoints (7 service-api + 1 integration-api) -- PUT: 5 endpoints -- DELETE: 1 endpoint +### Service API Endpoints: 59 + +| API area | Count | +|----------|-------| +| Asset Universe | 17 | +| Client | 5 | +| Investment Products | 4 | +| Portfolio | 3 | +| Portfolio Trading Accounts | 3 | +| Financial Advice (model portfolios) | 3 | +| Allocations | 3 | +| Payments | 2 | +| Broker (orders) | 2 | +| Content (entries + documents + tags) | 12 | +| Currency | 3 | +| Risk Assessment | 9 | +| Async Bulk Groups | 1 | + +### Integration API Endpoints: 0 + +`CustomIntegrationApiService` is no longer present in `investment-core`. + +### Grand Total: 59 unique API endpoints (path + HTTP method) + +By HTTP method: +- GET: 24 +- POST: 22 +- PATCH: 11 +- PUT: 8 +- DELETE: 1 diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java index 6fc355b79..8696f7b79 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java @@ -97,11 +97,12 @@ public Mono executeTask(InvestmentTask streamTask) { } log.info("Starting investment saga execution: taskId={}, taskName={}", streamTask.getId(), streamTask.getName()); - return this.upsertInvestmentPortfolioModels(streamTask) + return this.loadAssets(streamTask) .flatMap(this::loadAssets) .flatMap(this::upsertClients) .flatMap(this::upsertRiskQuestions) .flatMap(this::upsertRiskAssessments) + .flatMap(this::upsertInvestmentPortfolioModels) .flatMap(this::upsertInvestmentProducts) .flatMap(this::upsertInvestmentPortfolios) .flatMap(this::upsertPortfolioTradingAccounts) @@ -118,7 +119,7 @@ public Mono executeTask(InvestmentTask streamTask) { "Investment saga failed: " + throwable.getMessage()); streamTask.setState(State.FAILED); }) - .onErrorResume(throwable -> Mono.just(streamTask)); + .onErrorResume(_ -> Mono.just(streamTask)); } private Mono upsertInvestmentPortfolioDeposits(InvestmentTask investmentTask) { diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java index 0b0f2a8e1..fb9fc02bc 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java @@ -1,6 +1,7 @@ package com.backbase.stream.investment.service; import com.backbase.investment.api.service.v1.FinancialAdviceApi; +import com.backbase.investment.api.service.v1.model.AssetModelPortfolio; import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse; import com.backbase.stream.configuration.IngestConfigProperties; @@ -11,6 +12,7 @@ import com.backbase.stream.investment.service.resttemplate.RestTemplateModelPortfolioMapper; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -80,7 +82,12 @@ public Mono upsertModelPortfolio(InvestorModelPortfolio modelPor }) .doOnError(throwable -> log.error( "Failed to upsert investment portfolio model: name={}, riskLevel={}", - map.getName(), map.getRiskLevel(), throwable)); + map.getName(), map.getRiskLevel(), throwable)) + .onErrorResume(WebClientResponseException.class, throwable -> { + log.warn("Continuing without portfolio model: name={}, riskLevel={}", map.getName(), + map.getRiskLevel()); + return Mono.empty(); + }); } /** @@ -107,8 +114,14 @@ private Mono upsertModelPortfolio(ModelPortfolio mode return listExistingModelPortfolios(modelName, riskLevel) .flatMap(pm -> { - modelPortfolio.setAllocations(modelPortfolioMapper.mapAssetModel(pm.getAllocation())); - modelPortfolio.setCashWeight(pm.getCashWeight()); + if (isTargetAssetWeightCorrect(pm)) { + modelPortfolio.setAllocations(modelPortfolioMapper.mapAssetModel(pm.getAllocation())); + modelPortfolio.setCashWeight(pm.getCashWeight()); + } else { + log.error( + "The stored model target asset weight and cash weight is incorrect for uuid={}, name={}, riskLevel={}", + pm.getUuid(), pm.getName(), pm.getRiskLevel()); + } return patchModelPortfolio(pm.getUuid(), modelPortfolio); }) .switchIfEmpty(Mono.defer(() -> createNewModelPortfolio(modelPortfolio))) @@ -120,6 +133,12 @@ private Mono upsertModelPortfolio(ModelPortfolio mode modelName, riskLevel, throwable)); } + private boolean isTargetAssetWeightCorrect(InvestorModelPortfolio pm) { + double assetsWeight = Optional.ofNullable(pm.getAllocation()).orElse(List.of()).stream() + .mapToDouble(AssetModelPortfolio::getWeight).sum(); + return assetsWeight + pm.getCashWeight() == 1d; + } + /** * Lists existing model portfolios by name and risk level. * diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java index b175ee227..44cd64724 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java @@ -5,7 +5,6 @@ import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; import com.backbase.investment.api.service.v1.model.PortfolioProduct; -import com.backbase.investment.api.service.v1.model.PortfolioProductCreateUpdateRequest; import com.backbase.investment.api.service.v1.model.ProductTypeEnum; import com.backbase.stream.configuration.IngestConfigProperties; import com.backbase.stream.investment.InvestmentArrangement; @@ -263,28 +262,12 @@ private Mono updateExistingPortfolioProduct(PortfolioProduct e ProductPortfolio portfolioProduct, InvestmentData investmentData) { UUID productUuid = existingProduct.getUuid(); - PortfolioProductCreateUpdateRequest patch = new PortfolioProductCreateUpdateRequest() - .image(null) - .name(portfolioProduct.getName()) - .description(portfolioProduct.getDescription()) - .badge(portfolioProduct.getBadge()) - .externalId(portfolioProduct.getExternalId()) - .status(portfolioProduct.getStatus()) - .order(portfolioProduct.getOrder()) - .adviceEngine(portfolioProduct.getAdviceEngine()) - .modelPortfolio(Optional.ofNullable(portfolioProduct.getModelPortfolio()) - .map(InvestorModelPortfolio::getUuid) - .orElse(null)) - .productType(portfolioProduct.getProductType()) - .productCategory(portfolioProduct.getProductCategory()) - .extraData(portfolioProduct.getExtraData()); - log.debug("Patching existing portfolio product: uuid={}, name={}, productType={}", productUuid, portfolioProduct.getName(), portfolioProduct.getProductType()); - return productsApi.updatePortfolioProduct(productUuid.toString(), patch, + return investmentRestProductPortfolioService.patchPortfolioProduct(productUuid.toString(), List.of(config.getAllocation().getModelPortfolioAllocationAsset()), - null, null) + portfolioProduct) .doOnSuccess(updated -> { log.info("Successfully patched portfolio product: uuid={}, name={}, productType={}", updated.getUuid(), updated.getName(), updated.getProductType()); @@ -306,36 +289,6 @@ private Mono updateExistingPortfolioProduct(PortfolioProduct e .onErrorResume(WebClientResponseException.class, ex -> Mono.just(existingProduct)); } - // This is next step for implementation - /*private Mono updateExistingPortfolioProduct(PortfolioProduct existingProduct, - ProductPortfolio portfolioProduct, InvestmentData investmentData) { - UUID productUuid = existingProduct.getUuid(); - - log.debug("Attempting to patch existing portfolio product: uuid={}, productType={}", - productUuid, portfolioProduct.getProductType()); - - return investmentRestProductPortfolioService.patchPortfolioProduct(productUuid.toString(), - List.of(config.getAllocation().getModelPortfolioAllocationAsset()), portfolioProduct) - .doOnSuccess(updated -> { - log.info("Successfully patched existing investment product: uuid={}", updated.getUuid()); - investmentData.addPortfolioProducts(updated); - }) - .doOnError(throwable -> { - if (throwable instanceof WebClientResponseException ex) { - log.warn( - "PATCH portfolio product failed (falling back to existing): uuid={}, status={}, body={}", - productUuid, ex.getStatusCode(), ex.getResponseBodyAsString()); - } else { - log.warn("PATCH portfolio product failed (falling back to existing): uuid={}", - productUuid, throwable); - } - }) - .onErrorResume(WebClientResponseException.class, ex -> { - log.info("Using existing product data due to patch failure: uuid={}", productUuid); - return Mono.just(existingProduct); - }); - }*/ - /** * Creates a portfolio product with an optional model portfolio UUID. * @@ -352,22 +305,8 @@ private Mono createPortfolioProductWithModel(ProductPortfolio log.info("Creating portfolio product: name={}, productType={}, modelPortfolioUuid={}", portfolioProduct.getName(), productType, modelPortfolioUuid); - PortfolioProductCreateUpdateRequest request = new PortfolioProductCreateUpdateRequest() - .image(null) - .name(portfolioProduct.getName()) - .description(portfolioProduct.getDescription()) - .badge(portfolioProduct.getBadge()) - .externalId(portfolioProduct.getExternalId()) - .status(portfolioProduct.getStatus()) - .order(portfolioProduct.getOrder()) - .adviceEngine(portfolioProduct.getAdviceEngine()) - .modelPortfolio(modelPortfolioUuid) - .productType(portfolioProduct.getProductType()) - .productCategory(portfolioProduct.getProductCategory()) - .extraData(portfolioProduct.getExtraData()); - - return productsApi.createPortfolioProduct(request, - List.of(config.getAllocation().getModelPortfolioAllocationAsset()), null, null) + return investmentRestProductPortfolioService.createPortfolioProduct(portfolioProduct, + List.of(config.getAllocation().getModelPortfolioAllocationAsset())) .retry(2) .retryWhen(reactor.util.retry.Retry.fixedDelay(1, java.time.Duration.ofSeconds(1))) .doOnSuccess(created -> { @@ -380,29 +319,6 @@ private Mono createPortfolioProductWithModel(ProductPortfolio portfolioProduct.getName(), productType, throwable)); } - // This is next step for implementation - /*private Mono createPortfolioProductWithModel(ProductPortfolio portfolioProduct, - InvestmentData investmentData) { - - String productType = portfolioProduct.getProductType().getValue(); - UUID modelPortfolioUuid = Optional.ofNullable(portfolioProduct.getModelPortfolio()) - .map(InvestorModelPortfolio::getUuid).orElse(null); - log.info("Creating portfolio product: productType={}, modelPortfolioUuid={}", - productType, modelPortfolioUuid); - - return investmentRestProductPortfolioService.createPortfolioProduct(portfolioProduct, - List.of(config.getAllocation().getModelPortfolioAllocationAsset())) - .retry(2) - .retryWhen(reactor.util.retry.Retry.fixedDelay(1, java.time.Duration.ofSeconds(1))) - .doOnSuccess(created -> { - log.info( - "Successfully created portfolio product: uuid={}, productType={}, modelPortfolio={}", - created.getUuid(), created.getProductType(), modelPortfolioUuid); - investmentData.addPortfolioProducts(created); - }) - .doOnError(throwable -> logPortfolioProductCreationError(productType, throwable)); - }*/ - /** * Logs portfolio product creation errors with detailed information about the failure. * diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java index 16754671c..6122c9ac1 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java @@ -12,6 +12,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; @@ -139,7 +140,7 @@ private PortfolioProduct invokePatch(String uuid, List expand, ProductPo ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference<>() { }; - return apiClient.invokeAPI("/service-api/v2/products/portfolio/{uuid}/", HttpMethod.PATCH, pathParams, + return apiClient.invokeAPI("/service-api/v2/products/portfolio/{uuid}/", HttpMethod.PUT, pathParams, queryParams, null, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, new String[]{}, localVarReturnType).getBody(); } @@ -149,20 +150,22 @@ private PortfolioProduct invokePatch(String uuid, List expand, ProductPo Optional.ofNullable(data.getName()) .ifPresent(v -> formParams.add("name", v)); Optional.ofNullable(data.getDescription()) - .ifPresent(v -> formParams.add("description", v)); + .ifPresent(v -> formParams.add("description", v + " 2")); Optional.ofNullable(data.getBadge()) - .ifPresent(v -> formParams.add("badge", v)); + .ifPresent(v -> { + formParams.add("badge", apiClient.parameterToMultiValueMap(null, "badge", v)); + }); Optional.ofNullable(data.getExternalId()) .ifPresent(v -> formParams.add("external_id", v)); Optional.ofNullable(data.getStatus()) - .ifPresent(v -> formParams.add("status", v)); + .ifPresent(v -> formParams.add("status", v.getValue())); Optional.ofNullable(data.getOrder()) .ifPresent(v -> formParams.add("order", v)); Optional.ofNullable(data.getAdviceEngine()) .ifPresent(v -> formParams.add("advice_engine", v)); Optional.ofNullable(data.getModelPortfolio()) .ifPresent(v -> formParams.add("model_portfolio", - Optional.ofNullable(v).map(InvestorModelPortfolio::getUuid).orElse(null))); + Optional.ofNullable(v).map(InvestorModelPortfolio::getUuid).map(UUID::toString).orElse(null))); Optional.ofNullable(data.getProductType()) .ifPresent(v -> formParams.add("product_type", v.getValue())); Optional.ofNullable(data.getProductCategory()) From 0caebbb7131c15d34cf0e11a95ab348837f3ed0f Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 3 Jun 2026 12:28:53 +0300 Subject: [PATCH 06/11] TAR-797: BE: use new portfolio product properties and deprecate an old one improve code definition; improve logging info. --- .../stream/investment/ProductPortfolio.java | 19 ++-- .../investment/saga/InvestmentSaga.java | 70 +++++++----- .../InvestmentModelPortfolioService.java | 50 +++------ .../InvestmentPortfolioProductService.java | 100 ++++++++++-------- ...InvestmentRestProductPortfolioService.java | 54 ++++++---- 5 files changed, 160 insertions(+), 133 deletions(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java index 407b4621c..c88a58247 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/ProductPortfolio.java @@ -1,5 +1,12 @@ package com.backbase.stream.investment; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_ADVICE_ENGINE; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_EXTERNAL_ID; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_EXTRA_DATA; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_MODEL_PORTFOLIO; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY; +import static com.backbase.investment.api.service.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE; + import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.PortfolioProductBadge; import com.backbase.investment.api.service.v1.model.PortfolioProductStatusEnum; @@ -30,19 +37,19 @@ public class ProductPortfolio { private Resource imageResource; private Integer order; private PortfolioProductBadge badge; - @JsonProperty("external_id") + @JsonProperty(JSON_PROPERTY_EXTERNAL_ID) private String externalId; private PortfolioProductStatusEnum status = PortfolioProductStatusEnum.ACTIVE; - @JsonProperty("product_category") + @JsonProperty(JSON_PROPERTY_PRODUCT_CATEGORY) private String productCategory; private UUID uuid; - @JsonProperty("advice_engine") + @JsonProperty(JSON_PROPERTY_ADVICE_ENGINE) private String adviceEngine; - @JsonProperty("model_portfolio") + @JsonProperty(JSON_PROPERTY_MODEL_PORTFOLIO) private InvestorModelPortfolio modelPortfolio; - @JsonProperty("product_type") + @JsonProperty(JSON_PROPERTY_PRODUCT_TYPE) private ProductTypeEnum productType; - @JsonProperty("extra_data") + @JsonProperty(JSON_PROPERTY_EXTRA_DATA) private Map extraData = new HashMap<>(); } diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java index 8696f7b79..3421c950b 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentSaga.java @@ -69,6 +69,7 @@ public class InvestmentSaga implements StreamTaskExecutor { public static final String RESULT_FAILED = "failed"; private static final String INVESTMENT_PRODUCTS = "investment-products"; + private static final String INVESTMENT_PORTFOLIO_ALLOCATIONS = "investment-portfolio-allocations"; private static final String INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS = "investment-portfolio-trading-accounts"; private static final String INVESTMENT_RISK_ASSESSMENTS = "investment-risk-assessments"; private static final String INVESTMENT_RISK_QUESTIONS = "investment-risk-questions"; @@ -98,7 +99,6 @@ public Mono executeTask(InvestmentTask streamTask) { log.info("Starting investment saga execution: taskId={}, taskName={}", streamTask.getId(), streamTask.getName()); return this.loadAssets(streamTask) - .flatMap(this::loadAssets) .flatMap(this::upsertClients) .flatMap(this::upsertRiskQuestions) .flatMap(this::upsertRiskAssessments) @@ -108,9 +108,11 @@ public Mono executeTask(InvestmentTask streamTask) { .flatMap(this::upsertPortfolioTradingAccounts) .flatMap(this::upsertInvestmentPortfolioDeposits) .flatMap(this::upsertPortfoliosAllocations) - .doOnNext(completedTask -> log.info( - "Successfully completed investment saga: taskId={}, taskName={}, state={}", - completedTask.getId(), completedTask.getName(), completedTask.getState())) + .doOnSuccess(completedTask -> { + streamTask.setState(State.COMPLETED); + log.info("Successfully completed investment saga: taskId={}, taskName={}, state={}", + completedTask.getId(), completedTask.getName(), completedTask.getState()); + }) .doOnError(throwable -> { log.error("Failed to execute investment saga: taskId={}, taskName={}", streamTask.getId(), streamTask.getName(), throwable); @@ -126,12 +128,12 @@ private Mono upsertInvestmentPortfolioDeposits(InvestmentTask in return Flux.fromIterable(Objects.requireNonNullElse(investmentTask.getData().getPortfolios(), List.of())) .flatMap(investmentPortfolioService::upsertDeposits) .onErrorResume(throwable -> { - log.warn("Failed to create deposit for portfolio", throwable); + log.warn("Failed to create deposit for portfolio: taskId={}", investmentTask.getId(), throwable); return Mono.empty(); }) .flatMap(investmentPortfolioAllocationService::createDepositAllocation) .collectList() - .map(o -> investmentTask); + .map(_ -> investmentTask); } /** @@ -141,7 +143,7 @@ private Mono upsertInvestmentPortfolioDeposits(InvestmentTask in * Manual cleanup should be performed if necessary through the Investment Service API. * * @param streamTask the task to rollback - * @return null - rollback not implemented + * @return empty {@link Mono} — rollback not implemented */ @Override public Mono rollBack(InvestmentTask streamTask) { @@ -152,21 +154,27 @@ public Mono rollBack(InvestmentTask streamTask) { private Mono upsertPortfoliosAllocations(InvestmentTask investmentTask) { InvestmentData data = investmentTask.getData(); + List portfolios = Objects.requireNonNullElse(data.getPortfolios(), List.of()); return asyncTaskService.checkPriceAsyncTasksFinished(data.getPriceAsyncTasks()) - .thenMany(Flux.fromIterable(Objects.requireNonNullElse(data.getPortfolios(), List.of())) + .thenMany(Flux.fromIterable(portfolios) .flatMap( p -> investmentPortfolioAllocationService.generateAllocations(p, data.getIngestedPortfolioProducts(), investmentTask.getData().getInvestmentAssetData()))) .collectList() .doOnError(throwable -> { - log.error("Allocation generation failed for portfolios:{} taskId={}", - data.getPortfolios().stream().map(InvestmentPortfolio::getPortfolio).map(PortfolioList::getUuid) - .toList(), investmentTask.getId(), + log.error("Allocation generation failed: taskId={}, portfolioUuids={}", + investmentTask.getId(), + portfolios.stream() + .map(InvestmentPortfolio::getPortfolio) + .filter(Objects::nonNull) + .map(PortfolioList::getUuid) + .toList(), throwable); - investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, + investmentTask.error(INVESTMENT_PORTFOLIO_ALLOCATIONS, OP_UPSERT, RESULT_FAILED, investmentTask.getName(), investmentTask.getId(), - "Failed to upsert investment portfolio trading accounts: " + throwable.getMessage()); + "Failed to generate investment portfolio allocations: " + throwable.getMessage()); + investmentTask.setState(State.FAILED); }) .map(_ -> investmentTask); } @@ -205,7 +213,7 @@ private Mono upsertInvestmentPortfolios(InvestmentTask investmen investmentTask.info(INVESTMENT_PORTFOLIOS, OP_UPSERT, RESULT_CREATED, investmentTask.getName(), investmentTask.getId(), UPSERTED_PREFIX + portfolios.size() + " investment portfolios"); - investmentTask.setState(State.COMPLETED); + investmentTask.setPortfolios(portfolios); log.info("Successfully upserted all investment portfolios: taskId={}, portfolioCount={}", @@ -219,7 +227,6 @@ private Mono upsertInvestmentPortfolios(InvestmentTask investmen investmentTask.error(INVESTMENT_PORTFOLIOS, OP_UPSERT, RESULT_FAILED, investmentTask.getName(), investmentTask.getId(), "Failed to upsert investment portfolios: " + throwable.getMessage()); - investmentTask.setState(State.FAILED); }); } @@ -240,7 +247,7 @@ private Mono upsertInvestmentPortfolioModels(InvestmentTask inve investmentTask.getName(), investmentTask.getId(), UPSERTED_PREFIX + modelPortfolio.size() + " investment portfolio models"); - log.info("Successfully upserted all investment portfolio models: taskId={}, productCount={}", + log.info("Successfully upserted all investment portfolio models: taskId={}, modelCount={}", investmentTask.getId(), modelPortfolio.size()); return investmentTask; @@ -257,11 +264,22 @@ private Mono upsertInvestmentPortfolioModels(InvestmentTask inve private Mono loadAssets(InvestmentTask investmentTask) { if (coreConfigurationProperties.isAssetUniverseEnabled()) { - log.debug("Skip loading assets. Assets have to be provided on previous step"); + log.debug("Skipping asset load in investment saga; asset universe ingestion enabled: taskId={}", + investmentTask.getId()); return Mono.just(investmentTask); } - log.info("Loading assets"); - List assets = investmentTask.getData().getInvestmentAssetData().getAssets(); + InvestmentData data = investmentTask.getData(); + if (data.getInvestmentAssetData() == null) { + log.debug("Skipping asset load; no investment asset data on task: taskId={}", investmentTask.getId()); + return Mono.just(investmentTask); + } + List assets = Objects.requireNonNullElse(data.getInvestmentAssetData().getAssets(), List.of()); + if (assets.isEmpty()) { + log.debug("Skipping asset load; asset list is empty: taskId={}", investmentTask.getId()); + return Mono.just(investmentTask); + } + log.info("Loading assets from asset universe API: taskId={}, assetCount={}", + investmentTask.getId(), assets.size()); return Flux.fromIterable(assets) .flatMap(asset -> assetUniverseApi.getAsset(asset.getKeyString(), null, null, null) .map(a -> { @@ -269,7 +287,7 @@ private Mono loadAssets(InvestmentTask investmentTask) { return asset; })) .collectList() - .map(o -> investmentTask); + .map(_ -> investmentTask); } @@ -407,11 +425,11 @@ private static boolean isAssessmentApplicable(UserRiskAssessment a, String userN } private Mono upsertPortfolioTradingAccounts(InvestmentTask investmentTask) { - List investmentPortfolioTradingAccounts = investmentTask.getData() - .getInvestmentPortfolioTradingAccounts(); + List investmentPortfolioTradingAccounts = Objects.requireNonNullElse( + investmentTask.getData().getInvestmentPortfolioTradingAccounts(), List.of()); int accountsCount = investmentPortfolioTradingAccounts.size(); - log.info("Starting investment portfolio trading accounts upsert: taskId={}, arrangementCount={}", + log.info("Starting investment portfolio trading accounts upsert: taskId={}, accountsCount={}", investmentTask.getId(), accountsCount); investmentTask.info(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, null, investmentTask.getName(), @@ -422,12 +440,12 @@ private Mono upsertPortfolioTradingAccounts(InvestmentTask inves investmentTask.info(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_CREATED, investmentTask.getName(), investmentTask.getId(), UPSERTED_PREFIX + products.size() + " investment portfolio trading accounts"); - log.info("Successfully upserted all investment portfolio trading accounts: taskId={}, productCount={}", + log.info("Successfully upserted all investment portfolio trading accounts: taskId={}, accountsCount={}", investmentTask.getId(), products.size()); }) .thenReturn(investmentTask) .doOnError(throwable -> { - log.error("Failed to upsert investment portfolio trading accounts: taskId={}, arrangementCount={}", + log.error("Failed to upsert investment portfolio trading accounts: taskId={}, accountsCount={}", investmentTask.getId(), accountsCount, throwable); investmentTask.error(INVESTMENT_PORTFOLIO_TRADING_ACCOUNTS, OP_UPSERT, RESULT_FAILED, investmentTask.getName(), investmentTask.getId(), @@ -473,7 +491,6 @@ private Mono upsertClients(InvestmentTask streamTask) { streamTask.data(clients); streamTask.info(INVESTMENT, OP_UPSERT, RESULT_CREATED, streamTask.getName(), streamTask.getId(), UPSERTED_PREFIX + clients.size() + " investment clients"); - streamTask.setState(State.COMPLETED); log.info("Successfully upserted all clients: taskId={}, clientCount={}, successCount={}", streamTask.getId(), clientCount, clients.size()); @@ -486,7 +503,6 @@ private Mono upsertClients(InvestmentTask streamTask) { streamTask.error(INVESTMENT, OP_UPSERT, RESULT_FAILED, streamTask.getName(), streamTask.getId(), "Failed to upsert investment clients: " + throwable.getMessage()); - streamTask.setState(State.FAILED); }); } diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java index fb9fc02bc..02fc9df76 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentModelPortfolioService.java @@ -42,6 +42,8 @@ @RequiredArgsConstructor public class InvestmentModelPortfolioService { + private static final double WEIGHT_SUM_TOLERANCE = 1e-6; + private final FinancialAdviceApi financialAdviceApi; private final InvestmentRestModelPortfolioService investmentRestModelPortfolioService; private final IngestConfigProperties config; @@ -50,21 +52,8 @@ public class InvestmentModelPortfolioService { public Flux upsertModels(InvestmentData investmentData) { return Flux.fromIterable(Objects.requireNonNullElse(investmentData.getModelPortfolios(), List.of())) - .flatMap(modelPortfolioTemplate -> { - log.debug("Upserting investment portfolio model: name={}, riskLevel={}", - modelPortfolioTemplate.getName(), modelPortfolioTemplate.getRiskLevel()); - - return upsertModelPortfolio(modelPortfolioTemplate) - .doOnSuccess(modelPortfolio -> { - modelPortfolioTemplate.uuid(modelPortfolio.getUuid()); - log.debug( - "Successfully upserted investment portfolio model: modelUuid={}, name={}, riskLevel={}", - modelPortfolio.getUuid(), modelPortfolio.getName(), modelPortfolio.getRiskLevel()); - }) - .doOnError(throwable -> log.error( - "Failed to upsert investment portfolio model: name={}, riskLevel={}", - modelPortfolioTemplate.getName(), modelPortfolioTemplate.getRiskLevel(), throwable)); - }); + .flatMap(modelPortfolioTemplate -> upsertModelPortfolio(modelPortfolioTemplate) + .doOnSuccess(modelPortfolio -> modelPortfolioTemplate.uuid(modelPortfolio.getUuid()))); } public Mono upsertModelPortfolio(InvestorModelPortfolio modelPortfolio) { @@ -76,16 +65,9 @@ public Mono upsertModelPortfolio(InvestorModelPortfolio modelPor map.uuid(mp.getUuid()); return map; }) - .doOnSuccess(mp -> { - log.debug("Successfully upserted investment portfolio model: modelUuid={}, name={}, riskLevel={}", - mp.getUuid(), mp.getName(), mp.getRiskLevel()); - }) - .doOnError(throwable -> log.error( - "Failed to upsert investment portfolio model: name={}, riskLevel={}", - map.getName(), map.getRiskLevel(), throwable)) - .onErrorResume(WebClientResponseException.class, throwable -> { - log.warn("Continuing without portfolio model: name={}, riskLevel={}", map.getName(), - map.getRiskLevel()); + .onErrorResume(WebClientResponseException.class, ex -> { + log.warn("Continuing without portfolio model: name={}, riskLevel={}, status={}", + map.getName(), map.getRiskLevel(), ex.getStatusCode()); return Mono.empty(); }); } @@ -110,7 +92,7 @@ private Mono upsertModelPortfolio(ModelPortfolio mode String modelName = modelPortfolio.getName(); Integer riskLevel = modelPortfolio.getRiskLevel(); - log.info("Upserting model portfolio: name={}, riskLevel={}", modelName, riskLevel); + log.debug("Upserting model portfolio: name={}, riskLevel={}", modelName, riskLevel); return listExistingModelPortfolios(modelName, riskLevel) .flatMap(pm -> { @@ -119,7 +101,7 @@ private Mono upsertModelPortfolio(ModelPortfolio mode modelPortfolio.setCashWeight(pm.getCashWeight()); } else { log.error( - "The stored model target asset weight and cash weight is incorrect for uuid={}, name={}, riskLevel={}", + "Stored model target asset weight and cash weight are incorrect for uuid={}, name={}, riskLevel={}", pm.getUuid(), pm.getName(), pm.getRiskLevel()); } return patchModelPortfolio(pm.getUuid(), modelPortfolio); @@ -127,16 +109,14 @@ private Mono upsertModelPortfolio(ModelPortfolio mode .switchIfEmpty(Mono.defer(() -> createNewModelPortfolio(modelPortfolio))) .doOnSuccess(upserted -> log.info( "Successfully upserted model portfolio: uuid={}, name={}, riskLevel={}", - upserted.getUuid(), upserted.getName(), upserted.getRiskLevel())) - .doOnError(throwable -> log.error( - "Failed to upsert model portfolio: name={}, riskLevel={}", - modelName, riskLevel, throwable)); + upserted.getUuid(), upserted.getName(), upserted.getRiskLevel())); } private boolean isTargetAssetWeightCorrect(InvestorModelPortfolio pm) { + double cashWeight = Optional.ofNullable(pm.getCashWeight()).orElse(0d); double assetsWeight = Optional.ofNullable(pm.getAllocation()).orElse(List.of()).stream() .mapToDouble(AssetModelPortfolio::getWeight).sum(); - return assetsWeight + pm.getCashWeight() == 1d; + return Math.abs(assetsWeight + cashWeight - 1d) <= WEIGHT_SUM_TOLERANCE; } /** @@ -183,8 +163,8 @@ private Mono listExistingModelPortfolios(String name, In * @return Mono emitting the newly created model portfolio */ private Mono createNewModelPortfolio(ModelPortfolio modelPortfolio) { - log.debug("Creating new model portfolio: name={}, riskLevel={}, details={}", - modelPortfolio.getName(), modelPortfolio.getRiskLevel(), modelPortfolio); + log.info("Creating new model portfolio: name={}, riskLevel={}", + modelPortfolio.getName(), modelPortfolio.getRiskLevel()); return investmentRestModelPortfolioService.createModelPortfolio(modelPortfolio) .doOnError(throwable -> logModelPortfolioError("create", modelPortfolio.getName(), modelPortfolio.getRiskLevel(), throwable)); @@ -208,7 +188,7 @@ private Mono patchModelPortfolio(UUID uuid, ModelPort * @param riskLevel the risk level of the model portfolio * @param throwable the exception that occurred */ - private void logModelPortfolioError(String operation, String name, int riskLevel, Throwable throwable) { + private void logModelPortfolioError(String operation, String name, Integer riskLevel, Throwable throwable) { if (throwable instanceof WebClientResponseException ex) { log.error("Failed to {} model portfolio: name={}, riskLevel={}, status={}, body={}", operation, name, riskLevel, ex.getStatusCode(), ex.getResponseBodyAsString(), ex); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java index 44cd64724..ddd7b2c98 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java @@ -1,6 +1,5 @@ package com.backbase.stream.investment.service; -import com.backbase.investment.api.service.v1.FinancialAdviceApi; import com.backbase.investment.api.service.v1.InvestmentProductsApi; import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; @@ -13,6 +12,7 @@ import com.backbase.stream.investment.ProductPortfolio; import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService; import com.backbase.stream.investment.service.resttemplate.RestTemplateModelPortfolioMapper; +import java.time.Duration; import java.util.Collection; import java.util.Comparator; import java.util.List; @@ -25,23 +25,25 @@ import lombok.extern.slf4j.Slf4j; import org.mapstruct.factory.Mappers; import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; /** - * Service wrapper around generated {@link FinancialAdviceApi} providing guarded create/patch operations with logging, - * minimal idempotency helpers and consistent error handling. + * Service wrapper around {@link InvestmentProductsApi} and {@link InvestmentRestProductPortfolioService} providing + * guarded create/patch operations with logging, minimal idempotency helpers and consistent error handling. * *

This service manages: *

    - *
  • Investment portfolio model creation and updates
  • + *
  • Investment portfolio product creation and updates
  • *
* *

Design notes: *

    *
  • Side-effecting operations are logged at info (create) or debug (patch) levels
  • - *
  • Exceptions from the underlying WebClient are propagated (caller decides retry strategy)
  • + *
  • Exceptions from the underlying API clients are propagated (caller decides retry strategy)
  • *
  • All reactive operations include proper success and error handlers for observability
  • *
*/ @@ -71,14 +73,15 @@ public class InvestmentPortfolioProductService { *
  • Updates the arrangement with the product UUID
  • * * - * @param investmentArrangements the investment arrangement to associate with the product (must not be null) - * @return Mono emitting the created or updated portfolio product - * @throws NullPointerException if investmentArrangement is null + * @param investmentData the investment data context containing portfolio product templates + * @param investmentArrangements the investment arrangements to associate with products (must not be null) + * @return Mono emitting the created or updated portfolio products + * @throws NullPointerException if investmentArrangements is null */ public Mono> upsertInvestmentProducts(InvestmentData investmentData, List investmentArrangements) { if (investmentArrangements == null) { - return Mono.error(new NullPointerException("InvestmentArrangement must not be null")); + return Mono.error(new NullPointerException("investmentArrangements must not be null")); } return enrichPortfolioProductsWithModels(investmentData) .flatMapIterable(this::distinctProducts) @@ -113,10 +116,9 @@ private Mono> enrichPortfolioProductsWithModels(Investmen .flatMap(pp -> { Mono modelPortfolio = upsertPortfolioModel(pp); return modelPortfolio - // todo: improve upsert load .map(mp -> { - InvestorModelPortfolio map = modelPortfolioMapper.map(mp); - pp.setModelPortfolio(map); + InvestorModelPortfolio mappedModelPortfolio = modelPortfolioMapper.map(mp); + pp.setModelPortfolio(mappedModelPortfolio); return pp; }) .switchIfEmpty(Mono.just(pp)); @@ -186,6 +188,7 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po .map(InvestorModelPortfolio::getRiskLevel).orElse(null); ProductTypeEnum productType = portfolioProduct.getProductType(); + String productCategory = portfolioProduct.getProductCategory(); int pageSize = config.getPortfolio().getListProductPageSize(); AtomicInteger pageCounter = new AtomicInteger(0); @@ -202,8 +205,9 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po return productsApi.listPortfolioProducts( List.of(config.getAllocation().getModelPortfolioAllocationAsset()), null, null, - pageSize, null, null, riskLevel, nextPage * pageSize, - null, "-model_portfolio__risk_level", List.of(productType.getValue()), null, null); + pageSize, null, riskLevel, null, nextPage * pageSize, + null, "-model_portfolio__risk_level", List.of(productType.getValue()), List.of(productCategory), + null); }) .collectList() .doOnSuccess(products -> log.debug( @@ -230,19 +234,14 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po portfolioProduct.getName(), productType); return Mono.empty(); } - portfolioProducts = List.of(portfolioProducts.getLast()); int resultCount = portfolioProducts.size(); if (resultCount > 1) { - log.error("Data setup issue: found {} portfolio products with name={}, productType={}, " - + "expected exactly 1", + // TODO: verify duplicate portfolio product selection strategy (getLast vs fail) + log.warn("Found {} portfolio products matching name={}, productType={}, using most recent one", resultCount, portfolioProduct.getName(), productType); - return Mono.error(new IllegalStateException( - String.format("Data setup issue: Found %d portfolio products with name=%s, productType=%s, " - + "expected exactly 1. Please review product configuration.", - resultCount, portfolioProduct.getName(), productType))); } - PortfolioProduct existingProduct = portfolioProducts.get(0); + PortfolioProduct existingProduct = portfolioProducts.getLast(); log.info("Found existing portfolio product: uuid={}, name={}, productType={}", existingProduct.getUuid(), existingProduct.getName(), productType); return Mono.just(existingProduct); @@ -265,28 +264,17 @@ private Mono updateExistingPortfolioProduct(PortfolioProduct e log.debug("Patching existing portfolio product: uuid={}, name={}, productType={}", productUuid, portfolioProduct.getName(), portfolioProduct.getProductType()); - return investmentRestProductPortfolioService.patchPortfolioProduct(productUuid.toString(), + return investmentRestProductPortfolioService.updatePortfolioProduct(productUuid.toString(), List.of(config.getAllocation().getModelPortfolioAllocationAsset()), portfolioProduct) .doOnSuccess(updated -> { - log.info("Successfully patched portfolio product: uuid={}, name={}, productType={}", + log.debug("Successfully patched portfolio product: uuid={}, name={}, productType={}", updated.getUuid(), updated.getName(), updated.getProductType()); investmentData.addPortfolioProducts(updated); }) - .doOnError(throwable -> { - if (throwable instanceof WebClientResponseException ex) { - log.warn( - "PATCH portfolio product failed, using existing product: uuid={}, name={}, " - + "productType={}, status={}, body={}", - productUuid, portfolioProduct.getName(), portfolioProduct.getProductType(), - ex.getStatusCode(), ex.getResponseBodyAsString()); - } else { - log.warn( - "PATCH portfolio product failed, using existing product: uuid={}, name={}, productType={}", - productUuid, portfolioProduct.getName(), portfolioProduct.getProductType(), throwable); - } - }) - .onErrorResume(WebClientResponseException.class, ex -> Mono.just(existingProduct)); + .doOnError(throwable -> logPortfolioProductPatchError( + productUuid, portfolioProduct.getName(), portfolioProduct.getProductType(), throwable)) + .onErrorResume(HttpClientErrorException.class, ex -> Mono.just(existingProduct)); } /** @@ -307,10 +295,9 @@ private Mono createPortfolioProductWithModel(ProductPortfolio return investmentRestProductPortfolioService.createPortfolioProduct(portfolioProduct, List.of(config.getAllocation().getModelPortfolioAllocationAsset())) - .retry(2) - .retryWhen(reactor.util.retry.Retry.fixedDelay(1, java.time.Duration.ofSeconds(1))) + .retryWhen(Retry.fixedDelay(2, Duration.ofSeconds(1))) .doOnSuccess(created -> { - log.info( + log.debug( "Successfully created portfolio product: uuid={}, name={}, productType={}, modelPortfolioUuid={}", created.getUuid(), created.getName(), created.getProductType(), modelPortfolioUuid); investmentData.addPortfolioProducts(created); @@ -319,12 +306,38 @@ private Mono createPortfolioProductWithModel(ProductPortfolio portfolioProduct.getName(), productType, throwable)); } + /** + * Logs portfolio product patch errors with detailed information about the failure. + * + * @param productUuid the UUID of the portfolio product being patched + * @param productName the name of the portfolio product being patched + * @param productType the product type being patched + * @param throwable the exception that occurred during patching + */ + private void logPortfolioProductPatchError(UUID productUuid, String productName, ProductTypeEnum productType, + Throwable throwable) { + if (throwable instanceof WebClientResponseException ex) { + log.warn( + "PATCH portfolio product failed, falling back to existing product: uuid={}, name={}, " + + "productType={}, status={}, body={}", + productUuid, productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString()); + } else if (throwable instanceof HttpClientErrorException ex) { + log.warn( + "PATCH portfolio product failed: uuid={}, name={}, productType={}, status={}, body={}", + productUuid, productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString()); + } else { + log.error("PATCH portfolio product failed: uuid={}, name={}, productType={}", + productUuid, productName, productType, throwable); + } + } + /** * Logs portfolio product creation errors with detailed information about the failure. * - *

    Provides enhanced error context for WebClient exceptions including + *

    Provides enhanced error context for HTTP client exceptions including * HTTP status code and response body. * + * @param productName the name of the portfolio product being created * @param productType the product type being created * @param throwable the exception that occurred during creation */ @@ -332,6 +345,9 @@ private void logPortfolioProductCreationError(String productName, String product if (throwable instanceof WebClientResponseException ex) { log.error("Failed to create portfolio product: name={}, productType={}, status={}, body={}", productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString(), ex); + } else if (throwable instanceof HttpClientErrorException ex) { + log.error("Failed to create portfolio product: name={}, productType={}, status={}, body={}", + productName, productType, ex.getStatusCode(), ex.getResponseBodyAsString(), ex); } else { log.error("Failed to create portfolio product: name={}, productType={}", productName, productType, throwable); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java index 6122c9ac1..a7d5c0767 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java @@ -1,5 +1,18 @@ package com.backbase.stream.investment.service.resttemplate; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ADVICE_ENGINE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_BADGE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_DESCRIPTION; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTERNAL_ID; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTRA_DATA; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_IMAGE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_MODEL_PORTFOLIO; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_NAME; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ORDER; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_STATUS; + import com.backbase.investment.api.service.sync.ApiClient; import com.backbase.investment.api.service.sync.ApiClient.CollectionFormat; import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; @@ -12,7 +25,6 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; @@ -24,7 +36,6 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; /** @@ -70,16 +81,15 @@ public Mono createPortfolioProduct(ProductPortfolio productPor } /** - * Patches an existing portfolio product via {@code PATCH /service-api/v2/products/portfolio/{uuid}/}. + * Updates an existing portfolio product via {@code PATCH /service-api/v2/products/portfolio/{uuid}/}. * * @param uuid the UUID of the portfolio product to patch (must not be {@code null}) * @param expand optional fields to expand in the response * @param updateRequest the stream portfolio product with updated values * @return {@link Mono} emitting the patched {@link PortfolioProduct} */ - public Mono patchPortfolioProduct(String uuid, List expand, - ProductPortfolio updateRequest) - throws WebClientResponseException { + public Mono updatePortfolioProduct(String uuid, List expand, + ProductPortfolio updateRequest) { log.info("Starting portfolio product patch: uuid={}, name='{}', productType={}", uuid, updateRequest.getName(), updateRequest.getProductType()); @@ -88,7 +98,7 @@ public Mono patchPortfolioProduct(String uuid, List ex throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, "Missing the required parameter 'uuid' when calling patchPortfolioProduct"); } - return Mono.just(invokePatch(uuid, expand, updateRequest)); + return Mono.just(invokeUpdate(uuid, expand, updateRequest)); }) .map(patched -> { log.info("Portfolio product patched successfully: uuid={}, name='{}', productType={}", @@ -123,7 +133,7 @@ private PortfolioProduct invokeCreate(ProductPortfolio portfolioProduct, List expand, ProductPortfolio data) { + private PortfolioProduct invokeUpdate(String uuid, List expand, ProductPortfolio data) { final Map pathParams = new HashMap(); pathParams.put("uuid", uuid); final MultiValueMap formParams = productPortfolioParams(data); @@ -148,32 +158,30 @@ private PortfolioProduct invokePatch(String uuid, List expand, ProductPo private @NonNull MultiValueMap productPortfolioParams(ProductPortfolio data) { final MultiValueMap formParams = new LinkedMultiValueMap<>(); Optional.ofNullable(data.getName()) - .ifPresent(v -> formParams.add("name", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_NAME, v)); Optional.ofNullable(data.getDescription()) - .ifPresent(v -> formParams.add("description", v + " 2")); + .ifPresent(v -> formParams.add(JSON_PROPERTY_DESCRIPTION, v)); Optional.ofNullable(data.getBadge()) - .ifPresent(v -> { - formParams.add("badge", apiClient.parameterToMultiValueMap(null, "badge", v)); - }); + .ifPresent(v -> formParams.add(JSON_PROPERTY_BADGE, v)); Optional.ofNullable(data.getExternalId()) - .ifPresent(v -> formParams.add("external_id", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_EXTERNAL_ID, v)); Optional.ofNullable(data.getStatus()) - .ifPresent(v -> formParams.add("status", v.getValue())); + .ifPresent(v -> formParams.add(JSON_PROPERTY_STATUS, v)); Optional.ofNullable(data.getOrder()) - .ifPresent(v -> formParams.add("order", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_ORDER, v)); Optional.ofNullable(data.getAdviceEngine()) - .ifPresent(v -> formParams.add("advice_engine", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_ADVICE_ENGINE, v)); Optional.ofNullable(data.getModelPortfolio()) - .ifPresent(v -> formParams.add("model_portfolio", - Optional.ofNullable(v).map(InvestorModelPortfolio::getUuid).map(UUID::toString).orElse(null))); + .ifPresent(v -> formParams.add(JSON_PROPERTY_MODEL_PORTFOLIO, + Optional.of(v).map(InvestorModelPortfolio::getUuid).orElse(null))); Optional.ofNullable(data.getProductType()) - .ifPresent(v -> formParams.add("product_type", v.getValue())); + .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_TYPE, v.getValue())); Optional.ofNullable(data.getProductCategory()) - .ifPresent(v -> formParams.add("product_category", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_CATEGORY, v)); Optional.ofNullable(data.getExtraData()) - .ifPresent(v -> formParams.add("extra_data", v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_EXTRA_DATA, v)); if (ingestProperties.getPortfolio().isIngestImages()) { - Optional.ofNullable(data.getImageResource()).ifPresent(v -> formParams.add("image", v)); + Optional.ofNullable(data.getImageResource()).ifPresent(v -> formParams.add(JSON_PROPERTY_IMAGE, v)); } return formParams; } From b508828a26e5758fb2a1a79fbc18be71e0f9514b Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 3 Jun 2026 12:32:43 +0300 Subject: [PATCH 07/11] TAR-797: BE: use new portfolio product properties and deprecate an old one upgrade investment-service version to 1.6.2 --- stream-investment/investment-core/pom.xml | 2 +- .../service/InvestmentPortfolioProductService.java | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/stream-investment/investment-core/pom.xml b/stream-investment/investment-core/pom.xml index b410267a9..a04b7c8ea 100644 --- a/stream-investment/investment-core/pom.xml +++ b/stream-investment/investment-core/pom.xml @@ -15,7 +15,7 @@ true - 1.6.0 + 1.6.2 diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java index ddd7b2c98..ef423af57 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentPortfolioProductService.java @@ -193,8 +193,7 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po int pageSize = config.getPortfolio().getListProductPageSize(); AtomicInteger pageCounter = new AtomicInteger(0); return productsApi.listPortfolioProducts(List.of(config.getAllocation().getModelPortfolioAllocationAsset()), - null, null, - pageSize, null, null, null, null, null, "-model_portfolio__risk_level", + null, null, null, pageSize, null, null, null, null, null, "-model_portfolio__risk_level", List.of(productType.getValue()), null, null) .expand(response -> { if (response.getNext() == null) { @@ -204,8 +203,8 @@ private Mono listExistingPortfolioProducts(ProductPortfolio po return productsApi.listPortfolioProducts( List.of(config.getAllocation().getModelPortfolioAllocationAsset()), - null, null, - pageSize, null, riskLevel, null, nextPage * pageSize, + null, null, null, pageSize, null, riskLevel, + null, nextPage * pageSize, null, "-model_portfolio__risk_level", List.of(productType.getValue()), List.of(productCategory), null); }) From 5e48abb6b7982333162b8725092e77d1c0b60c96 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 3 Jun 2026 12:40:22 +0300 Subject: [PATCH 08/11] TAR-797: BE: use new portfolio product properties and deprecate an old one fix tests --- .../InvestmentModelPortfolioServiceTest.java | 121 ++++++++++-------- ...vestmentRestModelPortfolioServiceTest.java | 75 ++++++++++- 2 files changed, 142 insertions(+), 54 deletions(-) diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java index 9ec31f73b..0ce6031b7 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentModelPortfolioServiceTest.java @@ -4,18 +4,21 @@ import static org.mockito.ArgumentMatchers.any; 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.FinancialAdviceApi; +import com.backbase.investment.api.service.v1.model.AssetModelPortfolio; +import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse; -import com.backbase.investment.api.service.v1.model.PaginatedOASModelPortfolioResponseList; import com.backbase.stream.configuration.IngestConfigProperties; import com.backbase.stream.investment.Allocation; import com.backbase.stream.investment.InvestmentData; import com.backbase.stream.investment.ModelAsset; import com.backbase.stream.investment.ModelPortfolio; +import com.backbase.stream.investment.model.PaginatedExpandedModelPortfolioList; import com.backbase.stream.investment.service.resttemplate.InvestmentRestModelPortfolioService; import java.util.Collections; import java.util.List; @@ -29,6 +32,7 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -48,14 +52,16 @@ */ class InvestmentModelPortfolioServiceTest { + private static final String ALLOCATION_ASSET_EXPAND = "model_portfolio.allocation.asset"; + private static final int LIST_MODEL_PAGE_SIZE = 50; + @Mock private FinancialAdviceApi financialAdviceApi; @Mock private InvestmentRestModelPortfolioService investmentRestModelPortfolioService; - @Mock - private IngestConfigProperties ingestConfigProperties; + private final IngestConfigProperties ingestConfigProperties = new IngestConfigProperties(); private InvestmentModelPortfolioService service; @@ -64,7 +70,8 @@ class InvestmentModelPortfolioServiceTest { @BeforeEach void setUp() { mocks = MockitoAnnotations.openMocks(this); - service = new InvestmentModelPortfolioService(financialAdviceApi, investmentRestModelPortfolioService, ingestConfigProperties); + service = new InvestmentModelPortfolioService( + financialAdviceApi, investmentRestModelPortfolioService, ingestConfigProperties); } @AfterEach @@ -87,7 +94,7 @@ void upsertModels_nullModelPortfolios_emitsNothing() { StepVerifier.create(service.upsertModels(data)).verifyComplete(); - verify(financialAdviceApi, never()).listModelPortfolio( + verify(financialAdviceApi, never()).listModelPortfolioWithResponseSpec( any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); verify(investmentRestModelPortfolioService, never()).createModelPortfolio(any()); } @@ -99,7 +106,7 @@ void upsertModels_emptyModelPortfolios_emitsNothing() { StepVerifier.create(service.upsertModels(data)).verifyComplete(); - verify(financialAdviceApi, never()).listModelPortfolio( + verify(financialAdviceApi, never()).listModelPortfolioWithResponseSpec( any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); } @@ -110,7 +117,7 @@ void upsertModels_singlePortfolio_noExisting_createsAndSetsUuid() { ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Conservative", 3); + stubListReturnsEmpty("Conservative"); OASModelPortfolioResponse created = buildResponse(expectedUuid, "Conservative", 3); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn(Mono.just(created)); @@ -132,8 +139,7 @@ void upsertModels_singlePortfolio_existingFound_patches() { 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); + stubListReturnsOne("Balanced", 5, 0.2, existingUuid); OASModelPortfolioResponse patched = buildResponse(existingUuid, "Balanced", 5); when(investmentRestModelPortfolioService.patchModelPortfolio( @@ -156,8 +162,8 @@ void upsertModels_multiplePortfolios_processesAll() { ModelPortfolio t2 = buildModelPortfolio("Aggressive", 8, 0.05); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(t1, t2)).build(); - stubListReturnsEmpty("Conservative", 3); - stubListReturnsEmpty("Aggressive", 8); + stubListReturnsEmpty("Conservative"); + stubListReturnsEmpty("Aggressive"); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn( @@ -179,7 +185,7 @@ void upsertModels_createFails_propagatesError() { ModelPortfolio template = buildModelPortfolio("Conservative", 3, 0.1); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Conservative", 3); + stubListReturnsEmpty("Conservative"); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn(Mono.error(new RuntimeException("create failed"))); @@ -198,7 +204,7 @@ void upsertModels_passesCorrectModelPortfolioToRestService() { .name("Growth").riskLevel(7).cashWeight(0.25).allocations(List.of(allocation)).build(); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Growth", 7); + stubListReturnsEmpty("Growth"); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ModelPortfolio.class); when(investmentRestModelPortfolioService.createModelPortfolio(requestCaptor.capture())) @@ -235,7 +241,7 @@ void listExisting_emptyResults_createsNew() { ModelPortfolio template = buildModelPortfolio("Conservative", 2, 0.15); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Conservative", 2); + stubListReturnsEmpty("Conservative"); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn(Mono.just(buildResponse(expectedUuid, "Conservative", 2))); @@ -251,8 +257,7 @@ void listExisting_oneResult_patchesFirstResult() { ModelPortfolio template = buildModelPortfolio("Moderate", 5, 0.3); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse existing = buildResponse(existingUuid, "Moderate", 5); - stubListReturnsOne("Moderate", 5, existing); + stubListReturnsOne("Moderate", 5, 0.3, existingUuid); OASModelPortfolioResponse patched = buildResponse(existingUuid, "Moderate", 5); when(investmentRestModelPortfolioService.patchModelPortfolio( eq(existingUuid.toString()), any(ModelPortfolio.class))) @@ -273,14 +278,13 @@ void listExisting_multipleResults_patchesFirstResult() { ModelPortfolio template = buildModelPortfolio("Balanced", 6, 0.2); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse first = buildResponse(firstUuid, "Balanced", 6); - OASModelPortfolioResponse second = buildResponse(secondUuid, "Balanced", 6); - PaginatedOASModelPortfolioResponseList page = new PaginatedOASModelPortfolioResponseList() - .count(2).results(List.of(first, second)); - when(financialAdviceApi.listModelPortfolio( - isNull(), isNull(), isNull(), eq(1), eq("Balanced"), - isNull(), isNull(), isNull(), eq(6), isNull())) - .thenReturn(Mono.just(page)); + InvestorModelPortfolio first = buildInvestorModelPortfolio(firstUuid, "Balanced", 6, 0.2); + InvestorModelPortfolio second = buildInvestorModelPortfolio(secondUuid, "Balanced", 6, 0.2); + PaginatedExpandedModelPortfolioList page = PaginatedExpandedModelPortfolioList.builder() + .count(2) + .results(List.of(first, second)) + .build(); + stubListReturns(page, "Balanced"); OASModelPortfolioResponse patched = buildResponse(firstUuid, "Balanced", 6); when(investmentRestModelPortfolioService.patchModelPortfolio( @@ -300,9 +304,12 @@ void listExisting_apiError_propagatesError() { ModelPortfolio template = buildModelPortfolio("Balanced", 5, 0.2); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - when(financialAdviceApi.listModelPortfolio( - isNull(), isNull(), isNull(), eq(1), eq("Balanced"), - isNull(), isNull(), isNull(), eq(5), isNull())) + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + when(financialAdviceApi.listModelPortfolioWithResponseSpec( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE), + eq("Balanced"), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedModelPortfolioList.class)) .thenReturn(Mono.error(new RuntimeException("list API failed"))); StepVerifier.create(service.upsertModels(data)) @@ -326,7 +333,7 @@ void createNew_success_returnsCreatedResponse() { ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Income", 2); + stubListReturnsEmpty("Income"); OASModelPortfolioResponse created = buildResponse(newUuid, "Income", 2); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn(Mono.just(created)); @@ -346,7 +353,7 @@ void createNew_webClientResponseException_propagatesError() { ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Income", 2); + stubListReturnsEmpty("Income"); WebClientResponseException ex = WebClientResponseException.create( HttpStatus.BAD_REQUEST.value(), "Bad Request", null, null, null); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) @@ -363,7 +370,7 @@ void createNew_genericException_propagatesError() { ModelPortfolio template = buildModelPortfolio("Income", 2, 0.4); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - stubListReturnsEmpty("Income", 2); + stubListReturnsEmpty("Income"); when(investmentRestModelPortfolioService.createModelPortfolio(any(ModelPortfolio.class))) .thenReturn(Mono.error(new IllegalStateException("unexpected"))); @@ -388,8 +395,7 @@ void patch_success_returnsPatchedResponse() { ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); - stubListReturnsOne("Dynamic", 9, existing); + stubListReturnsOne("Dynamic", 9, 0.05, existingUuid); OASModelPortfolioResponse patched = buildResponse(existingUuid, "Dynamic", 9); when(investmentRestModelPortfolioService.patchModelPortfolio( eq(existingUuid.toString()), any(ModelPortfolio.class))) @@ -413,8 +419,7 @@ void patch_usesCorrectUuidFromExistingPortfolio() { ModelPortfolio template = buildModelPortfolio("Stable", 4, 0.3); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse existing = buildResponse(existingUuid, "Stable", 4); - stubListReturnsOne("Stable", 4, existing); + stubListReturnsOne("Stable", 4, 0.3, existingUuid); ArgumentCaptor uuidCaptor = ArgumentCaptor.forClass(String.class); OASModelPortfolioResponse patched = buildResponse(existingUuid, "Stable", 4); @@ -436,8 +441,7 @@ void patch_webClientResponseException_propagatesError() { ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); - stubListReturnsOne("Dynamic", 9, existing); + stubListReturnsOne("Dynamic", 9, 0.05, existingUuid); WebClientResponseException ex = WebClientResponseException.create( HttpStatus.NOT_FOUND.value(), "Not Found", null, null, null); when(investmentRestModelPortfolioService.patchModelPortfolio( @@ -456,8 +460,7 @@ void patch_genericException_propagatesError() { ModelPortfolio template = buildModelPortfolio("Dynamic", 9, 0.05); InvestmentData data = InvestmentData.builder().modelPortfolios(List.of(template)).build(); - OASModelPortfolioResponse existing = buildResponse(existingUuid, "Dynamic", 9); - stubListReturnsOne("Dynamic", 9, existing); + stubListReturnsOne("Dynamic", 9, 0.05, existingUuid); when(investmentRestModelPortfolioService.patchModelPortfolio( eq(existingUuid.toString()), any(ModelPortfolio.class))) .thenReturn(Mono.error(new RuntimeException("patch failed"))); @@ -486,20 +489,38 @@ private OASModelPortfolioResponse buildResponse(UUID uuid, String name, int risk return response; } - private void stubListReturnsEmpty(String name, int riskLevel) { - PaginatedOASModelPortfolioResponseList emptyPage = new PaginatedOASModelPortfolioResponseList() - .count(0).results(Collections.emptyList()); - when(financialAdviceApi.listModelPortfolio( - any(), any(), any(), eq(1), eq(name), any(), any(), any(), eq(riskLevel), any())) - .thenReturn(Mono.just(emptyPage)); + private InvestorModelPortfolio buildInvestorModelPortfolio( + UUID uuid, String name, int riskLevel, double cashWeight) { + double assetWeight = 1.0 - cashWeight; + AssetModelPortfolio allocation = new AssetModelPortfolio().weight(assetWeight); + return new InvestorModelPortfolio( + uuid, name, cashWeight, riskLevel, List.of(allocation), null, null); + } + + private void stubListReturnsEmpty(String name) { + PaginatedExpandedModelPortfolioList emptyPage = PaginatedExpandedModelPortfolioList.builder() + .count(0) + .results(Collections.emptyList()) + .build(); + stubListReturns(emptyPage, name); } - private void stubListReturnsOne(String name, int riskLevel, OASModelPortfolioResponse response) { - PaginatedOASModelPortfolioResponseList page = new PaginatedOASModelPortfolioResponseList() - .count(1).results(List.of(response)); - when(financialAdviceApi.listModelPortfolio( - any(), any(), any(), eq(1), eq(name), any(), any(), any(), eq(riskLevel), any())) + private void stubListReturnsOne(String name, int riskLevel, double cashWeight, UUID uuid) { + InvestorModelPortfolio existing = buildInvestorModelPortfolio(uuid, name, riskLevel, cashWeight); + PaginatedExpandedModelPortfolioList page = PaginatedExpandedModelPortfolioList.builder() + .count(1) + .results(List.of(existing)) + .build(); + stubListReturns(page, name); + } + + private void stubListReturns(PaginatedExpandedModelPortfolioList page, String name) { + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + when(financialAdviceApi.listModelPortfolioWithResponseSpec( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), eq(LIST_MODEL_PAGE_SIZE), + eq(name), isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(responseSpec); + when(responseSpec.bodyToMono(PaginatedExpandedModelPortfolioList.class)) .thenReturn(Mono.just(page)); } } - diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java index bee9a2ce5..a878755ed 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestModelPortfolioServiceTest.java @@ -3,9 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.backbase.investment.api.service.sync.ApiClient; +import com.backbase.investment.api.service.v1.model.OASModelPortfolioRequestDataRequest; import com.backbase.investment.api.service.v1.model.OASModelPortfolioResponse; import com.backbase.stream.investment.ModelPortfolio; import java.util.UUID; @@ -14,19 +17,29 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; import reactor.test.StepVerifier; @ExtendWith(MockitoExtension.class) class InvestmentRestModelPortfolioServiceTest { + private static final String FORM_PARAM_DATA = "data"; + @Mock private ApiClient apiClient; + @Captor + @SuppressWarnings("unchecked") + private ArgumentCaptor> formParamsCaptor; + private InvestmentRestModelPortfolioService service; @BeforeEach @@ -52,8 +65,9 @@ void successfulCreateReturnsResponse() { response.setName("Conservative"); response.setRiskLevel(3); - when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), - any())).thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + when(apiClient.invokeAPI(anyString(), eq(HttpMethod.POST), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); StepVerifier.create(service.createModelPortfolio(modelPortfolio)) .assertNext(result -> { @@ -62,6 +76,29 @@ void successfulCreateReturnsResponse() { assertThat(result.getRiskLevel()).isEqualTo(3); }) .verifyComplete(); + + assertThat(formParamsCaptor.getValue().containsKey(FORM_PARAM_DATA)).isTrue(); + assertThat(formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA)) + .isInstanceOf(OASModelPortfolioRequestDataRequest.class); + OASModelPortfolioRequestDataRequest dataRequest = + (OASModelPortfolioRequestDataRequest) formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA); + assertThat(dataRequest.getName()).isEqualTo("Conservative"); + assertThat(dataRequest.getRiskLevel()).isEqualTo(3); + } + + @Test + @DisplayName("create sends multipart form field named 'data'") + void createUsesDataFormParamName() { + ModelPortfolio modelPortfolio = buildModelPortfolio("Growth", 7); + + when(apiClient.invokeAPI(anyString(), eq(HttpMethod.POST), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + new OASModelPortfolioResponse(UUID.randomUUID()), HttpStatus.OK)); + + StepVerifier.create(service.createModelPortfolio(modelPortfolio)).expectNextCount(1).verifyComplete(); + + assertThat(formParamsCaptor.getValue().keySet()).containsExactly(FORM_PARAM_DATA); } @Test @@ -109,8 +146,9 @@ void successfulPatchReturnsResponse() { response.setName("Dynamic"); response.setRiskLevel(8); - when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), - any())).thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + when(apiClient.invokeAPI(anyString(), eq(HttpMethod.PUT), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); StepVerifier.create(service.patchModelPortfolio(existingUuid.toString(), modelPortfolio)) .assertNext(result -> { @@ -119,6 +157,35 @@ void successfulPatchReturnsResponse() { assertThat(result.getRiskLevel()).isEqualTo(8); }) .verifyComplete(); + + assertThat(formParamsCaptor.getValue().containsKey(FORM_PARAM_DATA)).isTrue(); + assertThat(formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA)) + .isInstanceOf(OASModelPortfolioRequestDataRequest.class); + OASModelPortfolioRequestDataRequest dataRequest = + (OASModelPortfolioRequestDataRequest) formParamsCaptor.getValue().getFirst(FORM_PARAM_DATA); + assertThat(dataRequest.getName()).isEqualTo("Dynamic"); + assertThat(dataRequest.getRiskLevel()).isEqualTo(8); + } + + @Test + @DisplayName("patch sends multipart form field named 'data'") + void patchUsesDataFormParamName() { + UUID existingUuid = UUID.randomUUID(); + ModelPortfolio modelPortfolio = buildModelPortfolio("Income", 2); + + when(apiClient.invokeAPI(anyString(), eq(HttpMethod.PUT), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + new OASModelPortfolioResponse(existingUuid), HttpStatus.OK)); + + StepVerifier.create(service.patchModelPortfolio(existingUuid.toString(), modelPortfolio)) + .expectNextCount(1) + .verifyComplete(); + + assertThat(formParamsCaptor.getValue().keySet()).containsExactly(FORM_PARAM_DATA); + verify(apiClient).invokeAPI( + eq("/service-api/v2/advice-engines/model-portfolio/model_portfolios/{uuid}/"), + eq(HttpMethod.PUT), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); } @Test From 83021dc1cf0a3d72c9bace8a000efc3c2a8339b5 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Wed, 3 Jun 2026 12:52:54 +0300 Subject: [PATCH 09/11] TAR-797: BE: use new portfolio product properties and deprecate an old one add code coverage --- .../investment/saga/InvestmentSagaTest.java | 242 ++++++++++++++++-- 1 file changed, 223 insertions(+), 19 deletions(-) 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 index b5e89943d..666e97a17 100644 --- 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 @@ -2,16 +2,23 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.backbase.investment.api.service.v1.AssetUniverseApi; +import com.backbase.investment.api.service.v1.model.Deposit; 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.Asset; import com.backbase.stream.investment.ClientUser; import com.backbase.stream.investment.InvestmentArrangement; +import com.backbase.stream.investment.InvestmentAssetData; import com.backbase.stream.investment.InvestmentData; import com.backbase.stream.investment.InvestmentTask; import com.backbase.stream.investment.ModelPortfolio; @@ -80,6 +87,9 @@ class InvestmentSagaTest { @Mock private InvestmentIngestionConfigurationProperties configurationProperties; + @Mock + private AssetUniverseApi assetUniverseApi; + private InvestmentSaga investmentSaga; private AutoCloseable mocks; @@ -98,7 +108,7 @@ void setUp() { investmentPortfolioProductService, asyncTaskService, configurationProperties, - null + assetUniverseApi ); } @@ -149,6 +159,100 @@ void executeTask_emptyTask_completesNormally() { .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) .verifyComplete(); } + + @Test + @DisplayName("should skip saga when wealth ingestion is disabled") + void executeTask_wealthDisabled_skipsPipeline() { + when(configurationProperties.isWealthEnabled()).thenReturn(false); + InvestmentTask task = createFullTask(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isNotEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(clientService, never()).upsertClients(any()); + verify(investmentPortfolioProductService, never()).upsertInvestmentProducts(any(), any()); + verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any()); + } + } + + // ========================================================================= + // loadAssets + // ========================================================================= + + @Nested + @DisplayName("loadAssets") + class LoadAssetsTests { + + @Test + @DisplayName("should skip asset load when asset universe ingestion is enabled") + void loadAssets_assetUniverseEnabled_skipsApiCall() { + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(true); + InvestmentTask task = createTaskWithAssets(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any()); + } + + @Test + @DisplayName("should skip asset load when investment asset data is null") + void loadAssets_noAssetData_skipsApiCall() { + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false); + InvestmentTask task = createMinimalTask(); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any()); + } + + @Test + @DisplayName("should skip asset load when asset list is empty") + void loadAssets_emptyAssetList_skipsApiCall() { + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false); + InvestmentTask task = new InvestmentTask("empty-assets", InvestmentData.builder() + .investmentAssetData(InvestmentAssetData.builder().assets(List.of()).build()) + .investmentArrangements(List.of()) + .clientUsers(List.of()) + .portfolios(List.of()) + .build()); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(assetUniverseApi, never()).getAsset(any(), any(), any(), any()); + } + + @Test + @DisplayName("should resolve asset UUIDs from asset universe API before upserting clients") + void loadAssets_withAssets_resolvesUuids() { + when(configurationProperties.isAssetUniverseEnabled()).thenReturn(false); + Asset streamAsset = Asset.builder().isin("US0378331005").market("XNAS").currency("USD").build(); + UUID resolvedUuid = UUID.randomUUID(); + com.backbase.investment.api.service.v1.model.Asset apiAsset = + new com.backbase.investment.api.service.v1.model.Asset(resolvedUuid); + + when(assetUniverseApi.getAsset(eq("US0378331005_XNAS_USD"), isNull(), isNull(), isNull())) + .thenReturn(reactor.core.publisher.Mono.just(apiAsset)); + + InvestmentTask task = createTaskWithAssets(streamAsset); + wireTrivialPipelineAfterModelPortfolios(); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(streamAsset.getUuid()).isEqualTo(resolvedUuid); + verify(assetUniverseApi).getAsset(eq("US0378331005_XNAS_USD"), isNull(), isNull(), isNull()); + } } // ========================================================================= @@ -261,7 +365,7 @@ void upsertRiskAssessments_error_marksTaskFailed() { when(investmentModelPortfolioService.upsertModels(any())) .thenReturn(Flux.just(new OASModelPortfolioResponse())); when(clientService.upsertClients(any())) - .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + .thenReturn(Mono.just(List.of(clientWithId()))); when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) .thenReturn(Mono.just(List.of())); when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) @@ -345,11 +449,18 @@ void upsertClients_emptyList_completesSuccessfully() { @DisplayName("should upsert clients and mark task COMPLETED") void upsertClients_success() { InvestmentTask task = createFullTask(); + ClientUser client = ClientUser.builder() + .investmentClientId(UUID.randomUUID()) + .legalEntityId(LE_INTERNAL_ID) + .build(); stubAllServicesSuccess(); + when(clientService.upsertClients(any())).thenReturn(Mono.just(List.of(client))); StepVerifier.create(investmentSaga.executeTask(task)) .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) .verifyComplete(); + + assertThat(task.getData().getClientUsers()).containsExactly(client); } @Test @@ -369,16 +480,16 @@ void upsertClients_error_marksTaskFailed() { } // ========================================================================= - // upsertArrangements + // upsertInvestmentProducts // ========================================================================= @Nested - @DisplayName("upsertArrangements") - class UpsertArrangementsTests { + @DisplayName("upsertInvestmentProducts") + class UpsertInvestmentProductsTests { @Test - @DisplayName("should complete successfully without calling service when arrangement list is empty") - void upsertArrangements_emptyList_completesSuccessfully() { + @DisplayName("should complete successfully when arrangement list is empty") + void upsertInvestmentProducts_emptyList_completesSuccessfully() { InvestmentTask task = createMinimalTask(); wireTrivialPipelineAfterModelPortfolios(); @@ -386,18 +497,37 @@ void upsertArrangements_emptyList_completesSuccessfully() { .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) .verifyComplete(); -// verify(investmentPortfolioService).upsertInvestmentProducts(any(), any()); + verify(investmentPortfolioProductService).upsertInvestmentProducts(any(), any()); + } + + @Test + @DisplayName("should upsert products and register them on investment data") + void upsertInvestmentProducts_success_registersProducts() { + InvestmentTask task = createFullTask(); + UUID productUuid = UUID.randomUUID(); + PortfolioProduct product = new PortfolioProduct( + "Robo", null, null, 1, null, "retail", productUuid, null, null, ProductTypeEnum.ROBO_ADVISOR); + stubAllServicesSuccess(); + when(investmentPortfolioProductService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(product))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentPortfolioProductService).upsertInvestmentProducts(task.getData(), task.getData().getInvestmentArrangements()); + assertThat(task.getData().getIngestedPortfolioProducts()).containsExactly(product); } @Test - @DisplayName("should mark task FAILED when arrangement upsert throws an error") - void upsertArrangements_error_marksTaskFailed() { + @DisplayName("should mark task FAILED when product upsert throws an error") + void upsertInvestmentProducts_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()))); + .thenReturn(Mono.just(List.of(clientWithId()))); when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) .thenReturn(Mono.just(List.of())); when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) @@ -432,6 +562,22 @@ void upsertPortfolios_emptyList_completesSuccessfully() { verify(investmentPortfolioService).upsertPortfolios(any(), any()); } + @Test + @DisplayName("should set portfolios on task data after successful upsert") + void upsertPortfolios_success_setsPortfoliosOnTask() { + InvestmentTask task = createFullTask(); + InvestmentPortfolio portfolio = InvestmentPortfolio.builder().portfolio(new PortfolioList()).build(); + stubAllServicesSuccess(); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of(portfolio))); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + assertThat(task.getData().getPortfolios()).containsExactly(portfolio); + } + @Test @DisplayName("should mark task FAILED when portfolio upsert throws an error") void upsertPortfolios_error_marksTaskFailed() { @@ -440,7 +586,7 @@ void upsertPortfolios_error_marksTaskFailed() { when(investmentModelPortfolioService.upsertModels(any())) .thenReturn(Flux.just(new OASModelPortfolioResponse())); when(clientService.upsertClients(any())) - .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + .thenReturn(Mono.just(List.of(clientWithId()))); when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) .thenReturn(Mono.just(List.of())); when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) @@ -496,7 +642,7 @@ void upsertPortfolioTradingAccounts_error_marksTaskFailed() { when(investmentModelPortfolioService.upsertModels(any())) .thenReturn(Flux.just(new OASModelPortfolioResponse())); when(clientService.upsertClients(any())) - .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + .thenReturn(Mono.just(List.of(clientWithId()))); when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) .thenReturn(Mono.just(List.of())); when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) @@ -531,6 +677,42 @@ void upsertDepositsAndAllocations_success() { StepVerifier.create(investmentSaga.executeTask(task)) .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) .verifyComplete(); + + verify(investmentPortfolioService).upsertDeposits(any()); + verify(investmentPortfolioAllocationService).createDepositAllocation(any()); + verify(asyncTaskService).checkPriceAsyncTasksFinished(any()); + } + + @Test + @DisplayName("should continue when deposit upsert fails for a portfolio") + void upsertDeposits_depositFailure_continuesAndCompletes() { + InvestmentTask task = createFullTask(); + when(investmentModelPortfolioService.upsertModels(any())) + .thenReturn(Flux.just(new OASModelPortfolioResponse())); + when(clientService.upsertClients(any())) + .thenReturn(Mono.just(List.of(clientWithId()))); + when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) + .thenReturn(Mono.just(List.of())); + when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) + .thenReturn(Mono.just(List.of())); + when(investmentPortfolioProductService.upsertInvestmentProducts(any(), any())) + .thenReturn(Mono.just(List.of(new PortfolioProduct()))); + when(investmentPortfolioService.upsertPortfolios(any(), any())) + .thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build()))); + when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) + .thenReturn(Mono.just(List.of())); + when(investmentPortfolioService.upsertDeposits(any())) + .thenReturn(Mono.error(new RuntimeException("deposit failed"))); + when(asyncTaskService.checkPriceAsyncTasksFinished(any())) + .thenReturn(Mono.empty()); + when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) + .thenReturn(Mono.empty()); + + StepVerifier.create(investmentSaga.executeTask(task)) + .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) + .verifyComplete(); + + verify(investmentPortfolioAllocationService, never()).createDepositAllocation(any()); } @Test @@ -541,7 +723,7 @@ void upsertDepositsAndAllocations_error_marksTaskFailed() { when(investmentModelPortfolioService.upsertModels(any())) .thenReturn(Flux.just(new OASModelPortfolioResponse())); when(clientService.upsertClients(any())) - .thenReturn(Mono.just(List.of(ClientUser.builder().build()))); + .thenReturn(Mono.just(List.of(clientWithId()))); when(investmentRiskQuestionaryService.upsertRiskQuestions(any())) .thenReturn(Mono.just(List.of())); when(investmentRiskAssessmentService.upsertRiskAssessments(any(), any())) @@ -551,9 +733,11 @@ void upsertDepositsAndAllocations_error_marksTaskFailed() { when(investmentPortfolioService.upsertPortfolios(any(), any())) .thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build()))); when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) - .thenReturn(Mono.empty()); + .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertDeposits(any())) - .thenReturn(Mono.empty()); + .thenReturn(Mono.just(new Deposit())); + when(investmentPortfolioAllocationService.createDepositAllocation(any())) + .thenReturn(Mono.just(new Deposit())); when(asyncTaskService.checkPriceAsyncTasksFinished(any())) .thenReturn(Mono.empty()); when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) @@ -610,6 +794,20 @@ private InvestmentTask createTaskWithRiskQuestions() { .build()); } + private InvestmentTask createTaskWithAssets(Asset... assets) { + return new InvestmentTask("asset-task", InvestmentData.builder() + .investmentAssetData(InvestmentAssetData.builder().assets(List.of(assets)).build()) + .clientUsers(Collections.emptyList()) + .investmentArrangements(Collections.emptyList()) + .portfolios(Collections.emptyList()) + .build()); + } + + private InvestmentTask createTaskWithAssets() { + return createTaskWithAssets( + Asset.builder().isin("US0378331005").market("XNAS").currency("USD").build()); + } + private InvestmentTask createFullTask() { return new InvestmentTask("full-task", InvestmentData.builder() .clientUsers(List.of(ClientUser.builder() @@ -648,15 +846,21 @@ private void stubAllServicesSuccess() { when(investmentPortfolioService.upsertPortfolios(any(), any())) .thenReturn(Mono.just(List.of(InvestmentPortfolio.builder().build()))); when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) - .thenReturn(Mono.empty()); + .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertDeposits(any())) - .thenReturn(Mono.empty()); + .thenReturn(Mono.just(new Deposit())); + when(investmentPortfolioAllocationService.createDepositAllocation(any())) + .thenReturn(Mono.just(new Deposit())); when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) .thenReturn(Mono.empty()); when(asyncTaskService.checkPriceAsyncTasksFinished(any())) .thenReturn(Mono.empty()); } + private ClientUser clientWithId() { + return ClientUser.builder().investmentClientId(UUID.randomUUID()).legalEntityId(LE_INTERNAL_ID).build(); + } + private void wireTrivialPipelineAfterModelPortfolios() { when(investmentModelPortfolioService.upsertModels(any())) .thenReturn(Flux.empty()); @@ -669,7 +873,7 @@ private void wireTrivialPipelineAfterModelPortfolios() { when(investmentPortfolioService.upsertPortfolios(any(), any())) .thenReturn(Mono.just(List.of())); when(investmentPortfolioService.upsertPortfolioTradingAccounts(any())) - .thenReturn(Mono.empty()); + .thenReturn(Mono.just(List.of())); when(investmentPortfolioAllocationService.generateAllocations(any(), any(), any())) .thenReturn(Mono.empty()); when(asyncTaskService.checkPriceAsyncTasksFinished(any())) From 66ac0d493316ee99528a5abdecd6a294a51007ee Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Thu, 4 Jun 2026 09:47:49 +0300 Subject: [PATCH 10/11] TAR-797: BE: use new portfolio product properties and deprecate an old one add tests --- ...InvestmentRestProductPortfolioService.java | 5 +- ...InvestmentPortfolioProductServiceTest.java | 408 ++++++++++++++++++ ...stmentRestProductPortfolioServiceTest.java | 329 ++++++++++++++ 3 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java create mode 100644 stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java index a7d5c0767..a274a820f 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java @@ -25,6 +25,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; @@ -166,14 +167,14 @@ private PortfolioProduct invokeUpdate(String uuid, List expand, ProductP Optional.ofNullable(data.getExternalId()) .ifPresent(v -> formParams.add(JSON_PROPERTY_EXTERNAL_ID, v)); Optional.ofNullable(data.getStatus()) - .ifPresent(v -> formParams.add(JSON_PROPERTY_STATUS, v)); + .ifPresent(v -> formParams.add(JSON_PROPERTY_STATUS, v.getValue())); Optional.ofNullable(data.getOrder()) .ifPresent(v -> formParams.add(JSON_PROPERTY_ORDER, v)); Optional.ofNullable(data.getAdviceEngine()) .ifPresent(v -> formParams.add(JSON_PROPERTY_ADVICE_ENGINE, v)); Optional.ofNullable(data.getModelPortfolio()) .ifPresent(v -> formParams.add(JSON_PROPERTY_MODEL_PORTFOLIO, - Optional.of(v).map(InvestorModelPortfolio::getUuid).orElse(null))); + Optional.of(v).map(InvestorModelPortfolio::getUuid).map(UUID::toString).orElse(null))); Optional.ofNullable(data.getProductType()) .ifPresent(v -> formParams.add(JSON_PROPERTY_PRODUCT_TYPE, v.getValue())); Optional.ofNullable(data.getProductCategory()) diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java new file mode 100644 index 000000000..11a1a1faa --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentPortfolioProductServiceTest.java @@ -0,0 +1,408 @@ +package com.backbase.stream.investment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.v1.InvestmentProductsApi; +import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; +import com.backbase.investment.api.service.v1.model.PaginatedPortfolioProductList; +import com.backbase.investment.api.service.v1.model.PortfolioProduct; +import com.backbase.investment.api.service.v1.model.ProductTypeEnum; +import com.backbase.stream.configuration.IngestConfigProperties; +import com.backbase.stream.investment.InvestmentArrangement; +import com.backbase.stream.investment.InvestmentData; +import com.backbase.stream.investment.ModelPortfolio; +import com.backbase.stream.investment.ProductPortfolio; +import com.backbase.stream.investment.service.resttemplate.InvestmentRestProductPortfolioService; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit test suite for {@link InvestmentPortfolioProductService}. + */ +class InvestmentPortfolioProductServiceTest { + + private static final String ALLOCATION_ASSET_EXPAND = "model_portfolio.allocation.asset"; + private static final String ORDERING = "-model_portfolio__risk_level"; + private static final int LIST_PRODUCT_PAGE_SIZE = 50; + + @Mock + private InvestmentProductsApi productsApi; + + @Mock + private InvestmentModelPortfolioService modelPortfolioService; + + @Mock + private InvestmentRestProductPortfolioService investmentRestProductPortfolioService; + + private final IngestConfigProperties ingestConfigProperties = new IngestConfigProperties(); + + private InvestmentPortfolioProductService service; + + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + service = new InvestmentPortfolioProductService( + productsApi, + ingestConfigProperties, + modelPortfolioService, + investmentRestProductPortfolioService); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Nested + @DisplayName("upsertInvestmentProducts") + class UpsertInvestmentProductsTests { + + @Test + @DisplayName("null arrangements — emits NullPointerException") + void nullArrangements_emitsNullPointerException() { + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of()).build(); + + StepVerifier.create(service.upsertInvestmentProducts(data, null)) + .expectError(NullPointerException.class) + .verify(); + + verify(productsApi, never()).listPortfolioProducts( + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("no portfolio product templates — returns empty list") + void noPortfolioProducts_returnsEmptyList() { + InvestmentData data = InvestmentData.builder().portfolioProducts(null).build(); + InvestmentArrangement arrangement = buildArrangement("self-trading", null); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products).isEmpty()) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any()); + assertThat(arrangement.getInvestmentProductId()).isNull(); + } + + @Test + @DisplayName("no existing product — creates via REST service and assigns to arrangement") + void noExistingProduct_createsAndAssigns() { + UUID createdUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading"); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + PortfolioProduct created = buildApiProduct(createdUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + when(investmentRestProductPortfolioService.createPortfolioProduct( + any(ProductPortfolio.class), eq(List.of(ALLOCATION_ASSET_EXPAND)))) + .thenReturn(Mono.just(created)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> { + assertThat(products).hasSize(1); + assertThat(products.getFirst().getUuid()).isEqualTo(createdUuid); + }) + .verifyComplete(); + + assertThat(arrangement.getInvestmentProductId()).isEqualTo(createdUuid); + assertThat(data.getIngestedPortfolioProducts()).containsExactly(created); + verify(investmentRestProductPortfolioService, never()).updatePortfolioProduct(any(), any(), any()); + } + + @Test + @DisplayName("existing product found — patches via REST service") + void existingProduct_patches() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading"); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing); + + PortfolioProduct patched = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(existingUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(patched)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid)) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any()); + assertThat(arrangement.getInvestmentProductId()).isEqualTo(existingUuid); + } + + @Test + @DisplayName("multiple matches — uses last result for patch") + void multipleMatches_usesLastResult() { + UUID firstUuid = UUID.randomUUID(); + UUID lastUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Robo Plan", ProductTypeEnum.ROBO_ADVISOR); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Robo Plan"); + + PortfolioProduct first = buildApiProduct(firstUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR, 1); + PortfolioProduct last = buildApiProduct(lastUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR, 2); + stubListReturnsProducts(ProductTypeEnum.ROBO_ADVISOR, first, last); + + when(investmentRestProductPortfolioService.updatePortfolioProduct( + eq(lastUuid.toString()), eq(List.of(ALLOCATION_ASSET_EXPAND)), any(ProductPortfolio.class))) + .thenReturn(Mono.just(last)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products.getFirst().getUuid()).isEqualTo(lastUuid)) + .verifyComplete(); + } + + @Test + @DisplayName("duplicate templates by name — processes only one product") + void duplicateTemplatesByName_processesOnce() { + UUID productUuid = UUID.randomUUID(); + ProductPortfolio first = buildTemplate("Dedup Product", ProductTypeEnum.SELF_TRADING); + ProductPortfolio second = buildTemplate("Dedup Product", ProductTypeEnum.SELF_TRADING); + second.setDescription("replacement"); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(first, second)).build(); + + InvestmentArrangement arr1 = buildArrangement(ProductTypeEnum.SELF_TRADING.getValue(), null); + InvestmentArrangement arr2 = buildArrangement(ProductTypeEnum.SELF_TRADING.getValue(), null); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + PortfolioProduct created = buildApiProduct(productUuid, "Dedup Product", ProductTypeEnum.SELF_TRADING, 1); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.just(created)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arr1, arr2))) + .assertNext(products -> assertThat(products).hasSize(1)) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, times(1)).createPortfolioProduct(any(), any()); + assertThat(arr1.getInvestmentProductId()).isEqualTo(productUuid); + assertThat(arr2.getInvestmentProductId()).isEqualTo(productUuid); + } + + @Test + @DisplayName("arrangement with productPortfolioName — matches product by name") + void arrangementWithPortfolioName_matchesByName() { + UUID matchingUuid = UUID.randomUUID(); + UUID otherUuid = UUID.randomUUID(); + ProductPortfolio targetTemplate = buildTemplate("Target Product", ProductTypeEnum.SELF_TRADING); + ProductPortfolio otherTemplate = buildTemplate("Other Product", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder() + .portfolioProducts(List.of(targetTemplate, otherTemplate)) + .build(); + + InvestmentArrangement arrangement = InvestmentArrangement.builder() + .name("Arrangement") + .productTypeExternalId(ProductTypeEnum.SELF_TRADING.getValue()) + .productPortfolioName("Target Product") + .build(); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + PortfolioProduct matching = buildApiProduct(matchingUuid, "Target Product", ProductTypeEnum.SELF_TRADING, 2); + PortfolioProduct other = buildApiProduct(otherUuid, "Other Product", ProductTypeEnum.SELF_TRADING, 1); + + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.just(matching), Mono.just(other)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products).hasSize(2)) + .verifyComplete(); + + assertThat(arrangement.getInvestmentProductId()).isEqualTo(matchingUuid); + } + + @Test + @DisplayName("template with model portfolio — upserts model before product create") + void templateWithModelPortfolio_upsertsModelFirst() { + UUID modelUuid = UUID.randomUUID(); + UUID productUuid = UUID.randomUUID(); + InvestorModelPortfolio investorModel = new InvestorModelPortfolio( + null, "Growth Model", 0.25, 7, null, null, null); + ProductPortfolio template = buildTemplate("Robo Product", ProductTypeEnum.ROBO_ADVISOR); + template.setModelPortfolio(investorModel); + template.setProductCategory("retail"); + + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), "Robo Product"); + + ModelPortfolio upsertedModel = ModelPortfolio.builder() + .uuid(modelUuid).name("Growth Model").riskLevel(7).cashWeight(0.25).build(); + when(modelPortfolioService.upsertModelPortfolio(investorModel)).thenReturn(Mono.just(upsertedModel)); + + stubListReturnsEmpty(ProductTypeEnum.ROBO_ADVISOR); + PortfolioProduct created = buildApiProduct(productUuid, "Robo Product", ProductTypeEnum.ROBO_ADVISOR, 1); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.just(created)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products).hasSize(1)) + .verifyComplete(); + + verify(modelPortfolioService).upsertModelPortfolio(investorModel); + ArgumentCaptor templateCaptor = ArgumentCaptor.forClass(ProductPortfolio.class); + verify(investmentRestProductPortfolioService).createPortfolioProduct( + templateCaptor.capture(), eq(List.of(ALLOCATION_ASSET_EXPAND))); + assertThat(templateCaptor.getValue().getModelPortfolio()).isNotNull(); + assertThat(templateCaptor.getValue().getModelPortfolio().getUuid()).isEqualTo(modelUuid); + } + + @Test + @DisplayName("patch fails with HttpClientErrorException — falls back to existing product") + void patchFailsWithHttpClientError_fallsBackToExisting() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), "Self Trading"); + + PortfolioProduct existing = buildApiProduct(existingUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1); + stubListReturnsProducts(ProductTypeEnum.SELF_TRADING, existing); + + HttpClientErrorException ex = HttpClientErrorException.create( + HttpStatus.BAD_REQUEST, "Bad Request", null, null, null); + when(investmentRestProductPortfolioService.updatePortfolioProduct(any(), any(), any())) + .thenReturn(Mono.error(ex)); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> { + assertThat(products).hasSize(1); + assertThat(products.getFirst().getUuid()).isEqualTo(existingUuid); + }) + .verifyComplete(); + + verify(investmentRestProductPortfolioService, never()).createPortfolioProduct(any(), any()); + assertThat(arrangement.getInvestmentProductId()).isEqualTo(existingUuid); + } + + @Test + @DisplayName("list API failure — propagates error") + void listApiFailure_propagatesError() { + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), + isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING), + eq(List.of(ProductTypeEnum.SELF_TRADING.getValue())), isNull(), isNull())) + .thenReturn(Mono.error(new RuntimeException("list failed"))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), null)))) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + @DisplayName("create API failure — propagates error") + void createApiFailure_propagatesError() { + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.error(new IllegalStateException("create failed"))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(buildArrangement( + ProductTypeEnum.SELF_TRADING.getValue(), null)))) + .expectError(IllegalStateException.class) + .verify(); + } + + @Test + @DisplayName("no matching product type for arrangement — arrangement product id stays null") + void noMatchingProductType_arrangementNotAssigned() { + ProductPortfolio template = buildTemplate("Self Trading", ProductTypeEnum.SELF_TRADING); + InvestmentData data = InvestmentData.builder().portfolioProducts(List.of(template)).build(); + InvestmentArrangement arrangement = buildArrangement( + ProductTypeEnum.ROBO_ADVISOR.getValue(), null); + + stubListReturnsEmpty(ProductTypeEnum.SELF_TRADING); + UUID productUuid = UUID.randomUUID(); + when(investmentRestProductPortfolioService.createPortfolioProduct(any(), any())) + .thenReturn(Mono.just(buildApiProduct( + productUuid, "Self Trading", ProductTypeEnum.SELF_TRADING, 1))); + + StepVerifier.create(service.upsertInvestmentProducts(data, List.of(arrangement))) + .assertNext(products -> assertThat(products).hasSize(1)) + .verifyComplete(); + + assertThat(arrangement.getInvestmentProductId()).isNull(); + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private ProductPortfolio buildTemplate(String name, ProductTypeEnum productType) { + ProductPortfolio template = new ProductPortfolio(); + template.setName(name); + template.setProductType(productType); + template.setProductCategory("default"); + return template; + } + + private PortfolioProduct buildApiProduct( + UUID uuid, String name, ProductTypeEnum productType, Integer order) { + return new PortfolioProduct( + name, null, null, order, null, "default", uuid, null, null, productType); + } + + private InvestmentArrangement buildArrangement(String productTypeExternalId, String productPortfolioName) { + return InvestmentArrangement.builder() + .name("Test Arrangement") + .productTypeExternalId(productTypeExternalId) + .productPortfolioName(productPortfolioName) + .build(); + } + + private void stubListReturnsEmpty(ProductTypeEnum productType) { + PaginatedPortfolioProductList emptyPage = new PaginatedPortfolioProductList() + .count(0) + .results(Collections.emptyList()); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), + isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING), + eq(List.of(productType.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(emptyPage)); + } + + private void stubListReturnsProducts(ProductTypeEnum productType, PortfolioProduct... products) { + PaginatedPortfolioProductList page = new PaginatedPortfolioProductList() + .count(products.length) + .results(List.of(products)); + when(productsApi.listPortfolioProducts( + eq(List.of(ALLOCATION_ASSET_EXPAND)), isNull(), isNull(), isNull(), eq(LIST_PRODUCT_PAGE_SIZE), + isNull(), isNull(), isNull(), isNull(), isNull(), eq(ORDERING), + eq(List.of(productType.getValue())), isNull(), isNull())) + .thenReturn(Mono.just(page)); + } +} diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java new file mode 100644 index 000000000..806102aac --- /dev/null +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioServiceTest.java @@ -0,0 +1,329 @@ +package com.backbase.stream.investment.service.resttemplate; + +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ADVICE_ENGINE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_DESCRIPTION; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTERNAL_ID; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_EXTRA_DATA; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_IMAGE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_MODEL_PORTFOLIO; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_NAME; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_ORDER; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_CATEGORY; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_PRODUCT_TYPE; +import static com.backbase.investment.api.service.sync.v1.model.PortfolioProduct.JSON_PROPERTY_STATUS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.backbase.investment.api.service.sync.ApiClient; +import com.backbase.investment.api.service.v1.model.InvestorModelPortfolio; +import com.backbase.investment.api.service.v1.model.PortfolioProduct; +import com.backbase.investment.api.service.v1.model.PortfolioProductStatusEnum; +import com.backbase.investment.api.service.v1.model.ProductTypeEnum; +import com.backbase.stream.configuration.IngestConfigProperties; +import com.backbase.stream.investment.ProductPortfolio; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class InvestmentRestProductPortfolioServiceTest { + + private static final String CREATE_PATH = "/service-api/v2/products/portfolio/"; + private static final String UPDATE_PATH = "/service-api/v2/products/portfolio/{uuid}/"; + private static final List EXPAND = List.of("model_portfolio.allocation.asset"); + + @Mock + private ApiClient apiClient; + + @Captor + @SuppressWarnings("unchecked") + private ArgumentCaptor> formParamsCaptor; + + @Captor + @SuppressWarnings("unchecked") + private ArgumentCaptor> queryParamsCaptor; + + private final IngestConfigProperties ingestProperties = new IngestConfigProperties(); + + private InvestmentRestProductPortfolioService service; + + @BeforeEach + void setUp() { + service = new InvestmentRestProductPortfolioService(apiClient, ingestProperties); + when(apiClient.selectHeaderAccept(any())).thenReturn(List.of(MediaType.APPLICATION_JSON)); + when(apiClient.selectHeaderContentType(any())).thenReturn(MediaType.MULTIPART_FORM_DATA); + when(apiClient.parameterToMultiValueMap(any(), eq("expand"), any())) + .thenReturn(new LinkedMultiValueMap<>()); + } + + @Nested + @DisplayName("createPortfolioProduct") + class CreatePortfolioProduct { + + @Test + @DisplayName("successful create returns the created PortfolioProduct") + void successfulCreateReturnsResponse() { + UUID createdUuid = UUID.randomUUID(); + ProductPortfolio template = buildFullTemplate(createdUuid); + PortfolioProduct response = buildApiProduct(createdUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR); + + when(apiClient.invokeAPI(eq(CREATE_PATH), eq(HttpMethod.POST), any(), queryParamsCaptor.capture(), + any(), any(), any(), formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)) + .assertNext(result -> { + assertThat(result.getUuid()).isEqualTo(createdUuid); + assertThat(result.getName()).isEqualTo("Robo Plan"); + assertThat(result.getProductType()).isEqualTo(ProductTypeEnum.ROBO_ADVISOR); + }) + .verifyComplete(); + + assertFormParamsMatchTemplate(formParamsCaptor.getValue(), createdUuid); + assertThat(queryParamsCaptor.getValue()).isNotNull(); + } + + @Test + @DisplayName("create uses direct JSON_PROPERTY form param names") + void createUsesDirectFormParamNames() { + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + + when(apiClient.invokeAPI(eq(CREATE_PATH), eq(HttpMethod.POST), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK)); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete(); + + MultiValueMap formParams = formParamsCaptor.getValue(); + assertThat(formParams.keySet()).contains( + JSON_PROPERTY_NAME, + JSON_PROPERTY_DESCRIPTION, + JSON_PROPERTY_EXTERNAL_ID, + JSON_PROPERTY_STATUS, + JSON_PROPERTY_ORDER, + JSON_PROPERTY_ADVICE_ENGINE, + JSON_PROPERTY_MODEL_PORTFOLIO, + JSON_PROPERTY_PRODUCT_TYPE, + JSON_PROPERTY_PRODUCT_CATEGORY, + JSON_PROPERTY_EXTRA_DATA); + assertThat(formParams.getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan"); + assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor"); + assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_CATEGORY)).isEqualTo("retail"); + assertThat(formParams.getFirst(JSON_PROPERTY_MODEL_PORTFOLIO)) + .isEqualTo(template.getModelPortfolio().getUuid().toString()); + } + + @Test + @DisplayName("create includes image form param when ingestImages is enabled") + void createIncludesImageWhenIngestEnabled() { + ingestProperties.getPortfolio().setIngestImages(true); + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + template.setImageResource(new ByteArrayResource("logo".getBytes()) { + @Override + public String getFilename() { + return "logo.png"; + } + }); + + when(apiClient.invokeAPI(any(), eq(HttpMethod.POST), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK)); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete(); + + assertThat(formParamsCaptor.getValue().containsKey(JSON_PROPERTY_IMAGE)).isTrue(); + } + + @Test + @DisplayName("create omits image form param when ingestImages is disabled") + void createOmitsImageWhenIngestDisabled() { + ingestProperties.getPortfolio().setIngestImages(false); + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + template.setImageResource(new ByteArrayResource("logo".getBytes())); + + when(apiClient.invokeAPI(any(), eq(HttpMethod.POST), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + buildApiProduct(UUID.randomUUID(), "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK)); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)).expectNextCount(1).verifyComplete(); + + assertThat(formParamsCaptor.getValue().containsKey(JSON_PROPERTY_IMAGE)).isFalse(); + } + + @Test + @DisplayName("API failure propagates as error signal") + void apiFailurePropagatesError() { + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + + when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RuntimeException("Network error")); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)) + .expectErrorMessage("Network error") + .verify(); + } + + @Test + @DisplayName("API returns null body propagates NullPointerException") + void nullResponseBodyPropagatesNullPointer() { + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + + when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(HttpStatus.OK)); + + StepVerifier.create(service.createPortfolioProduct(template, EXPAND)) + .expectError(NullPointerException.class) + .verify(); + } + } + + @Nested + @DisplayName("updatePortfolioProduct") + class UpdatePortfolioProduct { + + @Test + @DisplayName("successful update returns the patched PortfolioProduct") + void successfulUpdateReturnsResponse() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildFullTemplate(existingUuid); + PortfolioProduct response = buildApiProduct(existingUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR); + + when(apiClient.invokeAPI(eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), queryParamsCaptor.capture(), + any(), any(), any(), formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template)) + .assertNext(result -> { + assertThat(result.getUuid()).isEqualTo(existingUuid); + assertThat(result.getName()).isEqualTo("Robo Plan"); + }) + .verifyComplete(); + + assertFormParamsMatchTemplate(formParamsCaptor.getValue(), existingUuid); + verify(apiClient).invokeAPI( + eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("update uses direct JSON_PROPERTY form param names") + void updateUsesDirectFormParamNames() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildFullTemplate(existingUuid); + + when(apiClient.invokeAPI(eq(UPDATE_PATH), eq(HttpMethod.PUT), any(), any(), any(), any(), any(), + formParamsCaptor.capture(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>( + buildApiProduct(existingUuid, "Robo Plan", ProductTypeEnum.ROBO_ADVISOR), HttpStatus.OK)); + + StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template)) + .expectNextCount(1) + .verifyComplete(); + + assertThat(formParamsCaptor.getValue().getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan"); + assertThat(formParamsCaptor.getValue().getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor"); + } + + @Test + @DisplayName("null UUID emits HttpClientErrorException with BAD_REQUEST status") + void nullUuidEmitsHttpClientErrorException() { + ProductPortfolio template = buildFullTemplate(UUID.randomUUID()); + + StepVerifier.create(service.updatePortfolioProduct(null, EXPAND, template)) + .expectErrorSatisfies(err -> { + assertThat(err).isInstanceOf(HttpClientErrorException.class); + HttpClientErrorException ex = (HttpClientErrorException) err; + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + }) + .verify(); + } + + @Test + @DisplayName("API failure propagates as error signal") + void apiFailurePropagatesError() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildFullTemplate(existingUuid); + + when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RuntimeException("Connection refused")); + + StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template)) + .expectErrorMessage("Connection refused") + .verify(); + } + + @Test + @DisplayName("API returns null body propagates NullPointerException") + void nullResponseBodyPropagatesNullPointer() { + UUID existingUuid = UUID.randomUUID(); + ProductPortfolio template = buildFullTemplate(existingUuid); + + when(apiClient.invokeAPI(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(new ResponseEntity<>(HttpStatus.OK)); + + StepVerifier.create(service.updatePortfolioProduct(existingUuid.toString(), EXPAND, template)) + .expectError(NullPointerException.class) + .verify(); + } + } + + private ProductPortfolio buildFullTemplate(UUID modelPortfolioUuid) { + ProductPortfolio template = new ProductPortfolio(); + template.setName("Robo Plan"); + template.setDescription("Robo description"); + template.setProductType(ProductTypeEnum.ROBO_ADVISOR); + template.setProductCategory("retail"); + template.setOrder(1); + template.setExternalId("ext-robo-001"); + template.setStatus(PortfolioProductStatusEnum.ACTIVE); + template.setAdviceEngine("default-engine"); + template.setExtraData(Map.of("key", "value")); + template.setModelPortfolio(new InvestorModelPortfolio( + modelPortfolioUuid, "Growth Model", 0.25, 7, null, null, null)); + return template; + } + + private void assertFormParamsMatchTemplate(MultiValueMap formParams, UUID modelPortfolioUuid) { + assertThat(formParams.getFirst(JSON_PROPERTY_NAME)).isEqualTo("Robo Plan"); + assertThat(formParams.getFirst(JSON_PROPERTY_DESCRIPTION)).isEqualTo("Robo description"); + assertThat(formParams.getFirst(JSON_PROPERTY_EXTERNAL_ID)).isEqualTo("ext-robo-001"); + assertThat(formParams.getFirst(JSON_PROPERTY_STATUS)).isEqualTo(PortfolioProductStatusEnum.ACTIVE.getValue()); + assertThat(formParams.getFirst(JSON_PROPERTY_ORDER)).isEqualTo(1); + assertThat(formParams.getFirst(JSON_PROPERTY_ADVICE_ENGINE)).isEqualTo("default-engine"); + assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_TYPE)).isEqualTo("robo-advisor"); + assertThat(formParams.getFirst(JSON_PROPERTY_PRODUCT_CATEGORY)).isEqualTo("retail"); + assertThat(formParams.getFirst(JSON_PROPERTY_MODEL_PORTFOLIO)).isEqualTo(modelPortfolioUuid.toString()); + assertThat(formParams.getFirst(JSON_PROPERTY_EXTRA_DATA)).isEqualTo(Map.of("key", "value")); + } + + private PortfolioProduct buildApiProduct(UUID uuid, String name, ProductTypeEnum productType) { + return new PortfolioProduct( + name, null, null, 1, null, "retail", uuid, null, null, productType); + } +} From a85b8c7ee3e6626b1201b8473d969184261a6958 Mon Sep 17 00:00:00 2001 From: Roman Kniazevych Date: Thu, 4 Jun 2026 15:23:41 +0300 Subject: [PATCH 11/11] TAR-797: BE: use new portfolio product properties and deprecate an old one set product external id --- .../resttemplate/InvestmentRestProductPortfolioService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java index a274a820f..8f21aff04 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/resttemplate/InvestmentRestProductPortfolioService.java @@ -164,8 +164,8 @@ private PortfolioProduct invokeUpdate(String uuid, List expand, ProductP .ifPresent(v -> formParams.add(JSON_PROPERTY_DESCRIPTION, v)); Optional.ofNullable(data.getBadge()) .ifPresent(v -> formParams.add(JSON_PROPERTY_BADGE, v)); - Optional.ofNullable(data.getExternalId()) - .ifPresent(v -> formParams.add(JSON_PROPERTY_EXTERNAL_ID, v)); + formParams.add(JSON_PROPERTY_EXTERNAL_ID, Optional.ofNullable(data.getExternalId()) + .orElse(UUID.randomUUID().toString())); Optional.ofNullable(data.getStatus()) .ifPresent(v -> formParams.add(JSON_PROPERTY_STATUS, v.getValue())); Optional.ofNullable(data.getOrder())