diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a86be8f..548170594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog All notable changes to this project will be documented in this file. +## [9.10.0] +### Changed +- Investment service + - avoid redundant API calls by checking for unchanged data before patch/update for markets, market special days, asset category types, asset categories, assets and currencies + - improve structured logging across investment upsert operations and renamed few methods + ## [9.8.0] ### Changed - Create investment data based on exists entries in system diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/Asset.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/Asset.java index fe678c5af..4d629d3de 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/Asset.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/Asset.java @@ -7,21 +7,21 @@ import java.util.Map; import java.util.UUID; import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.core.io.Resource; /** * Lightweight projection of {@link com.backbase.investment.api.service.v1.model.Asset} that keeps the DTO immutable * while providing helpers to translate to/from the generated model. */ -@Setter -@Getter +@Data @NoArgsConstructor @AllArgsConstructor -@EqualsAndHashCode +@Builder(toBuilder = true) +@EqualsAndHashCode(exclude = {"uuid", "logo", "logoFile", "categories"}) public class Asset implements AssetKey { private UUID uuid; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/AssetCategoryEntry.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/AssetCategoryEntry.java index 3cba0158b..9fffb074c 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/AssetCategoryEntry.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/model/AssetCategoryEntry.java @@ -2,15 +2,15 @@ import java.util.UUID; import lombok.AllArgsConstructor; -import lombok.Getter; +import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.core.io.Resource; -@Setter -@Getter +@Data @NoArgsConstructor @AllArgsConstructor +@EqualsAndHashCode(exclude = {"uuid", "image", "imageResource"}) public class AssetCategoryEntry { private UUID uuid; diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSaga.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSaga.java index a4270d75e..e68ffdc0b 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSaga.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSaga.java @@ -80,7 +80,7 @@ public Mono executeTask(InvestmentAssetsTask streamTask) { .flatMap(this::upsertMarketSpecialDays) .flatMap(this::upsertAssetCategoryTypes) .flatMap(this::upsertAssetCategories) - .flatMap(this::createAssets) + .flatMap(this::upsertAssets) .flatMap(this::upsertPrices) .flatMap(this::createIntradayPrices) .doOnSuccess(completedTask -> log.info( @@ -116,7 +116,7 @@ public Mono rollBack(InvestmentAssetsTask streamTask) { private Mono upsertCurrencies(InvestmentAssetsTask investmentTask) { InvestmentAssetData investmentData = investmentTask.getData(); int currencyCount = investmentData.getCurrencies() != null ? investmentData.getCurrencies().size() : 0; - log.info("Starting investment currency creation: taskId={}, currencies={}", + log.info("Starting investment currency creation: taskId={}, currencyCount={}", investmentTask.getId(), currencyCount); // Log the start of market creation and set task state to IN_PROGRESS investmentTask.info(INVESTMENT, OP_CREATE, null, investmentTask.getName(), investmentTask.getId(), @@ -136,12 +136,12 @@ private Mono upsertCurrencies(InvestmentAssetsTask investm investmentTask.getId(), OP_UPSERT + " " + currencies.size() + " Investment Currencies"); investmentTask.setState(State.COMPLETED); - log.info("Successfully processed all currencies: taskId={}, marketCount={}", + log.info("Successfully processed all currencies: taskId={}, currencyCount={}", investmentTask.getId(), currencies.size()); return investmentTask; }) .doOnError(throwable -> { - log.error("Failed to create/upsert investment currencies: taskId={}, marketCount={}", + log.error("Failed to create/upsert investment currencies: taskId={}, currencyCount={}", investmentTask.getId(), currencyCount, throwable); investmentTask.error(INVESTMENT, OP_CREATE, RESULT_FAILED, investmentTask.getName(), investmentTask.getId(), @@ -363,13 +363,13 @@ public Mono upsertAssetCategoryTypes(InvestmentAssetsTask } /** - * Creates investment assets by invoking the asset universe service for each asset in the task data. Updates the + * Upserts investment assets by invoking the asset universe service for each asset in the task data. Updates the * task state and logs progress for observability. * * @param investmentTask the investment task containing asset data * @return Mono with updated assets and state */ - public Mono createAssets(InvestmentAssetsTask investmentTask) { + public Mono upsertAssets(InvestmentAssetsTask investmentTask) { InvestmentAssetData investmentData = investmentTask.getData(); int assetCount = investmentData.getAssets() != null ? investmentData.getAssets().size() : 0; @@ -387,7 +387,7 @@ public Mono createAssets(InvestmentAssetsTask investmentTa return Mono.just(investmentTask); } // Process each asset: create or get from asset universe service - return assetUniverseService.createAssets(investmentData.getAssets()) + return assetUniverseService.upsertAssets(investmentData.getAssets()) .collectList() .doOnSuccess(assets -> { investmentTask.setAssets(assets); diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/AssetMapper.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/AssetMapper.java index 5f5da106e..625d89c81 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/AssetMapper.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/AssetMapper.java @@ -1,8 +1,13 @@ package com.backbase.stream.investment.service; import com.backbase.investment.api.service.v1.model.AssetCategory; +import com.backbase.investment.api.service.v1.model.AssetCategoryTypeRequest; +import com.backbase.investment.api.service.v1.model.Market; +import com.backbase.investment.api.service.v1.model.MarketRequest; +import com.backbase.investment.api.service.v1.model.MarketSpecialDay; +import com.backbase.investment.api.service.v1.model.MarketSpecialDayRequest; import com.backbase.stream.investment.Asset; -import java.util.ArrayList; +import com.backbase.stream.investment.model.AssetCategoryEntry; import java.util.List; import java.util.Objects; import org.mapstruct.Mapper; @@ -12,13 +17,71 @@ @Mapper public interface AssetMapper { + /** + * Maps a v1 API {@link com.backbase.investment.api.service.v1.model.Asset} response to the + * stream {@link Asset} model. {@code logo} is explicitly ignored because the API model holds a + * {@link java.net.URI} (a full signed URL) while the stream model holds a {@code String} + * filename — the type mismatch prevents automatic mapping. Logo change detection is handled + * separately in {@code isLogoUnchanged()} via a URI {@code contains} check. + */ @Mapping(target = "categories", source = "categories", qualifiedByName = "mapCategories") @Mapping(target = "logo", ignore = true) Asset map(com.backbase.investment.api.service.v1.model.Asset asset); + /** + * Maps an existing {@link Market} response to a {@link MarketRequest} so the two can be compared + * field-by-field using the generated {@code equals()} method to detect whether a re-run carries + * identical data and the update can be skipped. + */ + MarketRequest toMarketRequest(Market market); + + /** + * Maps an existing {@link MarketSpecialDay} response to a {@link MarketSpecialDayRequest} for + * equality comparison before deciding whether to call the update API. + * + *

All data fields ({@code date}, {@code description}, {@code sessionStart}, + * {@code sessionEnd}, {@code market}) share the same name and type in both models so MapStruct + * maps them automatically. The {@code uuid} field on {@link MarketSpecialDay} has no target in + * {@link MarketSpecialDayRequest} and is silently ignored by MapStruct. + */ + MarketSpecialDayRequest toMarketSpecialDayRequest(MarketSpecialDay marketSpecialDay); + + /** + * Maps an existing {@link com.backbase.investment.api.service.v1.model.AssetCategoryType} response + * to an {@link AssetCategoryTypeRequest} for equality comparison before deciding whether to call + * the update API. + * + *

Both {@code code} and {@code name} share the same name and type ({@code String}) so MapStruct + * maps them automatically. The {@code uuid} field on + * {@link com.backbase.investment.api.service.v1.model.AssetCategoryType} has no target in + * {@link AssetCategoryTypeRequest} and is silently ignored by MapStruct. + */ + AssetCategoryTypeRequest toAssetCategoryTypeRequest( + com.backbase.investment.api.service.v1.model.AssetCategoryType assetCategoryType); + + /** + * Maps an existing {@link AssetCategory} (v1 response model) to an {@link AssetCategoryEntry} + * for equality comparison before deciding whether to call the patch API. + * + *

The content fields ({@code name}, {@code code}, {@code order}, {@code type}, + * {@code excerpt}, {@code description}) share the same name and type in both models and are + * mapped automatically. Two fields require explicit {@code ignore}: + *

    + *
  • {@code image} — type mismatch: {@code URI} on the response vs {@code String} on the + * entry. Image change detection is handled separately in + * {@code isImageUnchanged()} via a filename {@code contains} check on the URI.
  • + *
  • {@code imageResource} — no equivalent field on the response model.
  • + *
+ * The {@code uuid} is mapped normally (both sides are {@link java.util.UUID}) so that the + * returned entry can be used to set the uuid via {@code doOnSuccess} if needed. + */ + @Mapping(target = "imageResource", ignore = true) + @Mapping(target = "image", ignore = true) + AssetCategoryEntry toAssetCategoryEntry(AssetCategory assetCategory); + @Named("mapCategories") default List mapCategories(List categories) { - return Objects.requireNonNullElse(categories, new ArrayList()) + return Objects.requireNonNullElse(categories, List.of()) .stream().map(AssetCategory::getCode).toList(); } diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java index 1925af305..1455fdd39 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentAssetUniverseService.java @@ -11,6 +11,7 @@ import com.backbase.investment.api.service.v1.model.PaginatedAssetCategoryList; import com.backbase.stream.investment.model.AssetCategoryEntry; import com.backbase.stream.investment.service.resttemplate.InvestmentRestAssetUniverseService; +import java.net.URI; import java.time.Duration; import java.time.LocalDate; import java.util.List; @@ -38,11 +39,10 @@ public class InvestmentAssetUniverseService { private final AssetMapper assetMapper = Mappers.getMapper(AssetMapper.class); /** - * Gets an existing market by code, or creates it if not found (404). Handles 404 NOT_FOUND from getMarket by - * returning Mono.empty(), which triggers market creation via switchIfEmpty. + * Upserts a market by code: updates if changed, creates if not found. * - * @param marketRequest the market request details - * @return Mono representing the existing or newly created market + * @param marketRequest the market details + * @return the existing, updated, or newly created {@link Market} */ public Mono upsertMarket(MarketRequest marketRequest) { log.debug("Creating market: {}", marketRequest); @@ -55,14 +55,18 @@ public Mono upsertMarket(MarketRequest marketRequest) { return Mono.error(error); }) .flatMap(existingMarket -> { - log.info("Market already exists: {}", existingMarket.getCode()); - log.debug("Market already exists: {}", existingMarket); + log.info("Market already exists: code={}", existingMarket.getCode()); + // Skip the update if the incoming request carries the same data as what is already stored. + if (isMarketSame(marketRequest, existingMarket)) { + log.info("Skipping market update - no changes detected for code: {}", existingMarket.getCode()); + return Mono.just(existingMarket); + } return assetUniverseApi.updateMarket(existingMarket.getCode(), marketRequest) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(this::isRetryableError) .doBeforeRetry(signal -> log.warn("Retrying market update: code={}, attempt={}", marketRequest.getCode(), signal.totalRetries() + 1))) - .doOnSuccess(updatedMarket -> log.info("Updated market: {}", updatedMarket)) + .doOnSuccess(updatedMarket -> log.info("Updated market: code={}", updatedMarket.getCode())) .doOnError(error -> { if (error instanceof WebClientResponseException w) { log.error("Error updating market: {} : HTTP {} -> {}", marketRequest.getCode(), @@ -73,23 +77,24 @@ public Mono upsertMarket(MarketRequest marketRequest) { } }); }) - .switchIfEmpty(assetUniverseApi.createMarket(marketRequest) + .switchIfEmpty(Mono.defer(() -> assetUniverseApi.createMarket(marketRequest) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(this::isRetryableError) .doBeforeRetry(signal -> log.warn("Retrying market create: code={}, attempt={}", marketRequest.getCode(), signal.totalRetries() + 1))) - .doOnSuccess(createdMarket -> log.info("Created market: {}", createdMarket)) + .doOnSuccess(createdMarket -> log.info("Created market: code={}", createdMarket.getCode())) .doOnError(error -> log.error("Error creating market: {}", error.getMessage(), error)) - ); + )); } /** - * Gets an existing asset by its identifier, or creates it if not found (404). Handles 404 NOT_FOUND from getAsset - * by returning Mono.empty(), which triggers asset creation via switchIfEmpty. + * Upserts an asset by identifier: patches if changed, creates if not found. * - * @return Mono representing the existing or newly created asset + * @param asset the desired asset state + * @param categoryIdByCode category UUID lookup map keyed by code + * @return the existing, patched, or newly created asset */ - public Mono getOrCreateAsset(com.backbase.stream.investment.Asset asset, + public Mono upsertAsset(com.backbase.stream.investment.Asset asset, Map categoryIdByCode) { log.debug("Creating asset: {}", asset); @@ -101,18 +106,27 @@ public Mono getOrCreateAsset(com.backbase. // Handle 404 NOT_FOUND by returning Mono.empty() to trigger asset creation .onErrorResume(error -> { if (error instanceof org.springframework.web.reactive.function.client.WebClientResponseException.NotFound) { - log.info("Asset not found with Asset Identifier : {}", assetIdentifier); + log.info("Asset not found: assetIdentifier={}", assetIdentifier); return Mono.empty(); } // Propagate other errors return Mono.error(error); }) - // If asset exists, log and return it + // If asset exists, compare mapped fields and only patch when something changed .flatMap(a -> { - log.info("Asset already exists with Asset Identifier : {}", assetIdentifier); - return investmentRestAssetUniverseService.patchAsset(a, asset, categoryIdByCode).thenReturn(a); + log.info("Asset already exists: assetIdentifier={}", assetIdentifier); + // Map existing API asset; logo (URI vs String) and categories (order-insensitive) + // are checked separately inside isAssetSame(). + com.backbase.stream.investment.Asset existingMapped = assetMapper.map(a); + if (isAssetSame(asset, existingMapped, a.getLogo())) { + log.info("Skipping asset patch - no changes detected for assetIdentifier: {}", assetIdentifier); + return Mono.just(existingMapped); + } + log.info("Asset changed for assetIdentifier: {}", assetIdentifier); + // Stamp the server UUID back onto the patched asset so callers always get a non-null key. + return investmentRestAssetUniverseService.patchAsset(a, asset, categoryIdByCode) + .map(patchedAsset -> patchedAsset.toBuilder().uuid(a.getUuid()).build()); }) - .map(assetMapper::map) // If Mono is empty (asset not found), create the asset .switchIfEmpty(Mono.defer(() -> investmentRestAssetUniverseService.createAsset(asset, categoryIdByCode) .doOnSuccess(createdAsset -> log.info("Created asset with assetIdentifier: {}", assetIdentifier)) @@ -129,11 +143,10 @@ public Mono getOrCreateAsset(com.backbase. } /** - * Gets an existing market special day by date and market, or creates it if not found. Handles 404 or empty results - * by creating the market special day. + * Upserts a market special day: updates if changed, creates if not found. * - * @param marketSpecialDayRequest the request containing market and date details - * @return Mono\ representing the existing or newly created market special day + * @param marketSpecialDayRequest the market and date details + * @return the existing, updated, or newly created {@link MarketSpecialDay} */ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest marketSpecialDayRequest) { log.debug("Creating market special day: {}", marketSpecialDayRequest); @@ -154,8 +167,15 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar .filter(msd -> marketSpecialDayRequest.getMarket().equals(msd.getMarket())) .findFirst(); if (matchingSpecialDay.isPresent()) { - log.info("Market special day already exists for day: {}", marketSpecialDayRequest); - return assetUniverseApi.updateMarketSpecialDay(matchingSpecialDay.get().getUuid().toString(), + log.info("Market special day already exists: date={}, market={}", + date, marketSpecialDayRequest.getMarket()); + MarketSpecialDay existing = matchingSpecialDay.get(); + if (isMarketSpecialDaySame(marketSpecialDayRequest, existing)) { + log.info("Skipping market special day update - no changes detected for date: {}, market: {}", + date, marketSpecialDayRequest.getMarket()); + return Mono.just(existing); + } + return assetUniverseApi.updateMarketSpecialDay(existing.getUuid().toString(), marketSpecialDayRequest) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(this::isRetryableError) @@ -163,7 +183,8 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar "Retrying market special day update: date={}, attempt={}", date, signal.totalRetries() + 1))) .doOnSuccess(updatedMarketSpecialDay -> - log.info("Updated market special day: {}", updatedMarketSpecialDay)) + log.info("Updated market special day: date={}, market={}", + date, marketSpecialDayRequest.getMarket())) .doOnError(error -> { if (error instanceof WebClientResponseException w) { log.error("Error updating market special day : {} : HTTP {} -> {}", @@ -188,8 +209,8 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar .doBeforeRetry(signal -> log.warn( "Retrying market special day create: date={}, attempt={}", marketSpecialDayRequest.getDate(), signal.totalRetries() + 1))) - .doOnSuccess( - createdMarketSpecialDay -> log.info("Created market special day: {}", createdMarketSpecialDay)) + .doOnSuccess(createdMarketSpecialDay -> log.info("Created market special day: date={}, market={}", + marketSpecialDayRequest.getDate(), marketSpecialDayRequest.getMarket())) .doOnError(error -> { if (error instanceof WebClientResponseException w) { log.error("Error creating market special day : {} : HTTP {} -> {}", marketSpecialDayRequest, @@ -203,7 +224,14 @@ public Mono upsertMarketSpecialDay(MarketSpecialDayRequest mar )); } - public Flux createAssets(List assets) { + /** + * Upserts a list of assets: patches each if changed, creates if not found. + * Deduplicates by key before processing and limits concurrency to 5. + * + * @param assets the desired asset states + * @return the existing, patched, or newly created assets + */ + public Flux upsertAssets(List assets) { if (CollectionUtils.isEmpty(assets)) { return Flux.empty(); } @@ -228,7 +256,7 @@ public Flux createAssets(List this.getOrCreateAsset(asset, categoryIdByCode) + .flatMap(asset -> this.upsertAsset(asset, categoryIdByCode) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(this::isRetryableError) .doBeforeRetry(signal -> log.warn( @@ -238,6 +266,74 @@ public Flux createAssets(List representing the existing or newly created asset category + * @return {@code true} if both lists contain exactly the same codes + */ + private boolean areCategoriesSame(List existingCategories, List desiredCategories) { + List existing = Objects.requireNonNullElse(existingCategories, List.of()); + List desired = Objects.requireNonNullElse(desiredCategories, List.of()); + boolean same = existing.size() == desired.size() + && new java.util.HashSet<>(existing).containsAll(desired); + log.debug("Categories same check: existing={}, desired={}, same={}", existing, desired, same); + return same; + } + + /** + * Returns {@code true} if the stored file does not need re-uploading. + * No-ops when {@code desiredFilename} is {@code null}; requires upload when + * {@code existingUri} is {@code null}; otherwise checks that the URI contains the filename. + */ + private boolean isFileSame(URI existingUri, String desiredFilename) { + if (desiredFilename == null) { + return true; + } + if (existingUri == null) { + return false; + } + boolean same = existingUri.toString().contains(desiredFilename); + log.debug("File same check: desiredFilename='{}', existingUri='{}'", desiredFilename, existingUri); + return same; + } + + /** + * Upserts an asset category by code: patches if changed, creates if not found. + * + * @param assetCategoryEntry the desired asset category state + * @return the existing, patched, or newly created {@link AssetCategory} */ public Mono upsertAssetCategory(AssetCategoryEntry assetCategoryEntry) { if (assetCategoryEntry == null) { @@ -285,9 +431,38 @@ public Mono upsertAssetCategory(AssetCategoryEntry assetCategoryE .findAny()) .map(c -> { log.info("Asset category already exists for code: {}", assetCategoryEntry.getCode()); + // Compare content fields; uuid/image/imageResource are excluded and image is + // checked separately via isFileSame(). + AssetCategoryEntry existingEntry = assetMapper.toAssetCategoryEntry(c); + + if (isAssetCategorySame(assetCategoryEntry, existingEntry, c.getImage())) { + log.info("Skipping asset category patch - no changes detected for code: {}", + assetCategoryEntry.getCode()); + // Return a non-empty Mono so that switchIfEmpty is NOT triggered. + // A lightweight AssetCategory carrying only the uuid is sufficient. + assetCategoryEntry.setUuid(c.getUuid()); + return Mono.just(new AssetCategory(c.getUuid())); + } + log.info("Patching asset category for code: {}", assetCategoryEntry.getCode()); return investmentRestAssetUniverseService.patchAssetCategory( c.getUuid(), - assetCategoryEntry, assetCategoryEntry.getImageResource()); + assetCategoryEntry, assetCategoryEntry.getImageResource()) + .doOnSuccess(updatedCategory -> { + assetCategoryEntry.setUuid(updatedCategory.getUuid()); + log.info("Updated asset category: code={}", assetCategoryEntry.getCode()); + }) + .doOnError(error -> { + if (error instanceof WebClientResponseException w) { + log.error("Error updating asset category: {} : HTTP {} -> {}", + assetCategoryEntry.getCode(), + w.getStatusCode(), w.getResponseBodyAsString()); + } else { + log.error("Error updating asset category: {} : {}", + assetCategoryEntry.getCode(), + error.getMessage(), error); + } + }) + .onErrorResume(e -> Mono.empty()); }) .orElseGet(() -> { log.debug("No asset category exists for code: {}", assetCategoryEntry.getCode()); @@ -295,33 +470,32 @@ public Mono upsertAssetCategory(AssetCategoryEntry assetCategoryE }) .switchIfEmpty( Mono.defer(() -> investmentRestAssetUniverseService - .createAssetCategory(assetCategoryEntry, assetCategoryEntry.getImageResource())) + .createAssetCategory(assetCategoryEntry, assetCategoryEntry.getImageResource()) + .doOnSuccess(createdCategory -> { + assetCategoryEntry.setUuid(createdCategory.getUuid()); + log.info("Created asset category: code={}", assetCategoryEntry.getCode()); + }) + .doOnError(error -> { + if (error instanceof WebClientResponseException w) { + log.error("Error creating asset category: {} : HTTP {} -> {}", + assetCategoryEntry.getCode(), + w.getStatusCode(), w.getResponseBodyAsString()); + } else { + log.error("Error creating asset category: {} : {}", + assetCategoryEntry.getCode(), + error.getMessage(), error); + } + }) + .onErrorResume(e -> Mono.empty())) ) - .doOnSuccess(updatedCategory -> { - assetCategoryEntry.setUuid(updatedCategory.getUuid()); - log.info("Updated asset category: {}", updatedCategory); - }) - .doOnError(error -> { - if (error instanceof WebClientResponseException w) { - log.error("Error updating asset category: {} : HTTP {} -> {}", - assetCategoryEntry.getCode(), - w.getStatusCode(), w.getResponseBodyAsString()); - } else { - log.error("Error updating asset category: {} : {}", - assetCategoryEntry.getCode(), - error.getMessage(), error); - } - }) - .onErrorResume(e -> Mono.empty()) ); } /** - * Gets an existing asset category type by its code, or creates it if not found. Handles 404 or empty results by - * creating the asset category type. + * Upserts an asset category type by code: updates if changed, creates if not found. * - * @param assetCategoryTypeRequest the request containing asset category type details - * @return Mono representing the existing or newly created asset category type + * @param assetCategoryTypeRequest the desired asset category type state + * @return the existing, updated, or newly created {@link AssetCategoryType} */ public Mono upsertAssetCategoryType(AssetCategoryTypeRequest assetCategoryTypeRequest) { if (assetCategoryTypeRequest == null) { @@ -341,14 +515,22 @@ public Mono upsertAssetCategoryType(AssetCategoryTypeRequest if (matchingType.isPresent()) { log.info("Asset category type already exists for code: {}", assetCategoryTypeRequest.getCode()); - return assetUniverseApi.updateAssetCategoryType(matchingType.get().getUuid().toString(), + AssetCategoryType existingType = matchingType.get(); + // Skip update if data is the same as what is stored. + if (isAssetCategoryTypeSame(assetCategoryTypeRequest, existingType)) { + log.info("Skipping asset category type update - no changes detected for code: {}", + assetCategoryTypeRequest.getCode()); + return Mono.just(existingType); + } + return assetUniverseApi.updateAssetCategoryType(existingType.getUuid().toString(), assetCategoryTypeRequest) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(this::isRetryableError) .doBeforeRetry(signal -> log.warn( "Retrying asset category type update: code={}, attempt={}", assetCategoryTypeRequest.getCode(), signal.totalRetries() + 1))) - .doOnSuccess(updatedType -> log.info("Updated asset category type: {}", updatedType)) + .doOnSuccess(updatedType -> log.info("Updated asset category type: code={}", + assetCategoryTypeRequest.getCode())) .doOnError(error -> { if (error instanceof WebClientResponseException w) { log.error("Error updating asset category type: {} : HTTP {} -> {}", @@ -373,7 +555,8 @@ public Mono upsertAssetCategoryType(AssetCategoryTypeRequest .doBeforeRetry(signal -> log.warn( "Retrying asset category type create: code={}, attempt={}", assetCategoryTypeRequest.getCode(), signal.totalRetries() + 1))) - .doOnSuccess(createdType -> log.info("Created asset category type: {}", createdType)) + .doOnSuccess(createdType -> log.info("Created asset category type: code={}", + assetCategoryTypeRequest.getCode())) .doOnError(error -> { if (error instanceof WebClientResponseException w) { log.error("Error creating asset category type: {} : HTTP {} -> {}", diff --git a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentCurrencyService.java b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentCurrencyService.java index 6fb279207..a01fc0945 100644 --- a/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentCurrencyService.java +++ b/stream-investment/investment-core/src/main/java/com/backbase/stream/investment/service/InvestmentCurrencyService.java @@ -68,6 +68,14 @@ private Mono upsertSingleCurrency(Currency currency) { .findFirst()) .flatMap(existingCurrency -> { if (existingCurrency.isPresent()) { + Currency existing = existingCurrency.get(); + // Skip the update entirely when name and symbol are unchanged — the code is the + // lookup key and cannot change without a delete/re-create cycle anyway. + if (Objects.equals(existing.getName(), currency.getName()) + && Objects.equals(existing.getSymbol(), currency.getSymbol())) { + log.info("Currency unchanged - skipping update: code='{}'", currency.getCode()); + return Mono.just(existing); + } log.info("Currency already exists: code='{}', updating", currency.getCode()); return updateCurrency(currency); } else { diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java index 2d42d4c61..7254ebe13 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/saga/InvestmentAssetUniverseSagaTest.java @@ -217,7 +217,7 @@ void executeTask_featureFlagDisabled_skipsAllProcessing() { verify(assetUniverseService, never()).upsertMarketSpecialDay(any()); verify(assetUniverseService, never()).upsertAssetCategoryType(any()); verify(assetUniverseService, never()).upsertAssetCategory(any()); - verify(assetUniverseService, never()).createAssets(any()); + verify(assetUniverseService, never()).upsertAssets(any()); verify(investmentAssetPriceService, never()).ingestPrices(any(), any()); verify(investmentIntradayAssetPriceService, never()).ingestIntradayPrices(); }) @@ -673,19 +673,19 @@ void upsertAssetCategories_error_marksTaskFailed() { } // ========================================================================= - // createAssets + // upsertAssets // ========================================================================= /** - * Tests for the {@code createAssets} stage of the saga pipeline. + * Tests for the {@code upsertAssets} stage of the saga pipeline. * *

The stage follows asset categories. An empty asset list must short-circuit without - * calling {@link InvestmentAssetUniverseService#createAssets}, while a non-empty list + * calling {@link InvestmentAssetUniverseService#upsertAssets}, while a non-empty list * must delegate to the service and store the resulting assets on the task. */ @Nested - @DisplayName("createAssets") - class CreateAssetsTests { + @DisplayName("upsertAssets") + class UpsertAssetsTests { /** * Verifies that when the asset list is empty, the saga skips asset creation @@ -693,7 +693,7 @@ class CreateAssetsTests { */ @Test @DisplayName("should skip asset creation and set COMPLETED when asset list is empty") - void createAssets_emptyList_setsCompleted() { + void upsertAssets_emptyList_setsCompleted() { InvestmentAssetsTask task = createMinimalTask(); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) @@ -707,19 +707,19 @@ void createAssets_emptyList_setsCompleted() { .assertNext(result -> assertThat(result.getState()).isEqualTo(State.COMPLETED)) .verifyComplete(); - verify(assetUniverseService, never()).createAssets(anyList()); + verify(assetUniverseService, never()).upsertAssets(anyList()); } /** - * Verifies that when assets are present, {@code createAssets} is invoked and + * Verifies that when assets are present, {@code upsertAssets} is invoked and * the task completes with {@link State#COMPLETED}. */ @Test - @DisplayName("should create assets and set them on the task on success") - void createAssets_success() { + @DisplayName("should upsert assets and set them on the task on success") + void upsertAssets_success() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(Collections.emptyList())); @@ -734,15 +734,15 @@ void createAssets_success() { } /** - * Verifies that a failure in {@code createAssets} causes the task to be marked + * Verifies that a failure in {@code upsertAssets} causes the task to be marked * {@link State#FAILED} without propagating an error signal. */ @Test - @DisplayName("should propagate error and mark task FAILED when asset creation fails") - void createAssets_error_marksTaskFailed() { + @DisplayName("should propagate error and mark task FAILED when asset upsert fails") + void upsertAssets_error_marksTaskFailed() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.error(new RuntimeException("Asset creation failure"))); StepVerifier.create(saga.executeTask(task)) @@ -775,7 +775,7 @@ void upsertPrices_success_immediateCompletion() { InvestmentAssetsTask task = createTaskWithAssets(); GroupResult groupResult = new GroupResult(UUID.randomUUID(), "PENDING", null); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(List.of(groupResult))); @@ -798,7 +798,7 @@ void upsertPrices_success_immediateCompletion() { void upsertPrices_emptyGroupResults() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(Collections.emptyList())); @@ -820,7 +820,7 @@ void upsertPrices_emptyGroupResults() { void upsertPrices_error_marksTaskFailed() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.error(new RuntimeException("Price ingestion failure"))); @@ -878,7 +878,7 @@ void createIntradayPrices_success() { InvestmentAssetsTask task = createTaskWithAssets(); GroupResult groupResult = new GroupResult(UUID.randomUUID(), "SUCCESS", null); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(List.of(groupResult))); @@ -901,7 +901,7 @@ void createIntradayPrices_success() { void createIntradayPrices_error_marksTaskFailed() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(Collections.emptyList())); @@ -923,7 +923,7 @@ void createIntradayPrices_error_marksTaskFailed() { void createIntradayPrices_asyncCheckFails_marksTaskFailed() { InvestmentAssetsTask task = createTaskWithAssets(); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(Collections.emptyList())); @@ -1060,7 +1060,7 @@ private InvestmentAssetsTask createFullTask() { * Configures all mocked services to return successful responses for a fully populated task. * Intended for use with {@link #createFullTask()} in end-to-end happy-path tests. * - * @param task the task whose asset list is used to stub {@code createAssets} + * @param task the task whose asset list is used to stub {@code upsertAssets} */ private void stubAllServicesSuccess(InvestmentAssetsTask task) { when(investmentCurrencyService.upsertCurrencies(anyList())) @@ -1080,7 +1080,7 @@ private void stubAllServicesSuccess(InvestmentAssetsTask task) { .thenReturn(Mono.just( new com.backbase.investment.api.service.sync.v1.model.AssetCategory() .name("TECH"))); - when(assetUniverseService.createAssets(anyList())) + when(assetUniverseService.upsertAssets(anyList())) .thenReturn(Flux.fromIterable(task.getData().getAssets())); when(investmentAssetPriceService.ingestPrices(anyList(), anyMap())) .thenReturn(Mono.just(Collections.emptyList())); diff --git a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java index 9c995802c..737e512e0 100644 --- a/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java +++ b/stream-investment/investment-core/src/test/java/com/backbase/stream/investment/service/InvestmentAssetUniverseServiceTest.java @@ -24,6 +24,7 @@ import com.backbase.investment.api.service.v1.model.PaginatedMarketSpecialDayList; import com.backbase.stream.investment.model.AssetCategoryEntry; import com.backbase.stream.investment.service.resttemplate.InvestmentRestAssetUniverseService; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.ArrayList; @@ -89,6 +90,33 @@ void setUp() { @DisplayName("upsertMarket") class UpsertMarketTests { + @Test + @DisplayName("market already exists with identical data — updateMarket is skipped and existing market returned") + void upsertMarket_marketExistsUnchanged_updateSkipped() { + // Arrange — request and existing market carry exactly the same fields + MarketRequest request = new MarketRequest() + .code("US") + .name("US Market") + .sessionStart("09:30") + .sessionEnd("16:00"); + Market existing = new Market() + .code("US") + .name("US Market") + .sessionStart("09:30") + .sessionEnd("16:00"); + + when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); + // createMarket is always evaluated eagerly as the switchIfEmpty argument; stub to avoid NPE + when(assetUniverseApi.createMarket(any())).thenReturn(Mono.just(new Market())); + + // Act & Assert — existing market returned, no update call made + StepVerifier.create(service.upsertMarket(request)) + .expectNext(existing) + .verifyComplete(); + + verify(assetUniverseApi, never()).updateMarket(any(), any()); + } + @Test @DisplayName("market already exists — updateMarket is called and updated market returned") void upsertMarket_marketExists_updateCalledAndReturned() { @@ -186,9 +214,9 @@ void upsertMarket_notFoundAndCreateFails_errorPropagated() { @Test @DisplayName("503 on updateMarket — retries exhausted, RetryExhaustedException propagated") void upsertMarket_503OnUpdate_retriesExhaustedErrorPropagated() { - // Arrange + // Arrange — existing has a name; request does not, so the data differs and update is triggered MarketRequest request = new MarketRequest().code("US"); - Market existing = new Market().code("US"); + Market existing = new Market().code("US").name("US Market"); when(assetUniverseApi.getMarket("US")).thenReturn(Mono.just(existing)); when(assetUniverseApi.updateMarket("US", request)) @@ -222,11 +250,11 @@ void upsertMarket_nonRetryableHttpErrorOnCreate_propagatedImmediately() { } // ========================================================================= - // getOrCreateAsset + // upsertAsset // ========================================================================= /** - * Tests for {@link InvestmentAssetUniverseService#getOrCreateAsset}. + * Tests for {@link InvestmentAssetUniverseService#upsertAsset}. * *

Covers: *

    @@ -239,14 +267,119 @@ void upsertMarket_nonRetryableHttpErrorOnCreate_propagatedImmediately() { *
*/ @Nested - @DisplayName("getOrCreateAsset") - class GetOrCreateAssetTests { + @DisplayName("upsertAsset") + class UpsertAssetTests { + + @Test + @DisplayName("asset already exists with identical data and no logo configured — patchAsset is skipped") + void upsertAsset_assetExistsUnchangedNoLogo_patchSkipped() { + // Arrange — desired asset has no logo, data fields identical → skip + com.backbase.stream.investment.Asset req = buildAsset(); // logo=null + com.backbase.investment.api.service.v1.model.Asset existingApiAsset = + new com.backbase.investment.api.service.v1.model.Asset() + .isin("ABC123").market("market").currency("USD"); + + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) + .thenReturn(Mono.just(existingApiAsset)); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); + + StepVerifier.create(service.upsertAsset(req, Map.of())) + .expectNextMatches(a -> "ABC123".equals(a.getIsin())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService, never()) + .patchAsset(any(com.backbase.investment.api.service.v1.model.Asset.class), + any(com.backbase.stream.investment.Asset.class), any()); + } + + @Test + @DisplayName("asset exists, logo filename found in server URI — patchAsset is skipped") + void upsertAsset_assetExistsLogoFilenameInServerUri_patchSkipped() { + // Arrange — server returns a signed URI that contains the desired logo filename + com.backbase.stream.investment.Asset req = buildAsset(); + req.setLogo("apple.png"); // desired filename + + com.backbase.investment.api.service.v1.model.Asset existingApiAsset = + new com.backbase.investment.api.service.v1.model.Asset() + .isin("ABC123").market("market").currency("USD") + .logo(URI.create("http://azurite:10000/account1/assets/logos/apple.png?se=2029-05-25&sp=r")); + + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) + .thenReturn(Mono.just(existingApiAsset)); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); + + // Act & Assert — URI contains "apple.png" → logo unchanged → skip + StepVerifier.create(service.upsertAsset(req, Map.of())) + .expectNextMatches(a -> "ABC123".equals(a.getIsin())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService, never()) + .patchAsset(any(com.backbase.investment.api.service.v1.model.Asset.class), + any(com.backbase.stream.investment.Asset.class), any()); + } + + @Test + @DisplayName("asset exists, logo filename NOT in server URI — patchAsset IS called") + void upsertAsset_assetExistsLogoFilenameNotInServerUri_patchCalled() { + // Arrange — server URI contains "old-logo.png", desired is "new-logo.png" + com.backbase.stream.investment.Asset req = buildAsset(); + req.setLogo("new-logo.png"); + + com.backbase.investment.api.service.v1.model.Asset existingApiAsset = + new com.backbase.investment.api.service.v1.model.Asset() + .isin("ABC123").market("market").currency("USD") + .logo(URI.create("http://azurite:10000/account1/assets/logos/old-logo.png?se=2029-05-25")); + + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) + .thenReturn(Mono.just(existingApiAsset)); + com.backbase.stream.investment.Asset patchedAsset = buildAsset(); + when(investmentRestAssetUniverseService.patchAsset(eq(existingApiAsset), eq(req), any())) + .thenReturn(Mono.just(patchedAsset)); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); + + // Act & Assert — URI does not contain "new-logo.png" → patch IS called + StepVerifier.create(service.upsertAsset(req, Map.of())) + .expectNextMatches(a -> "ABC123".equals(a.getIsin())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService) + .patchAsset(eq(existingApiAsset), eq(req), any()); + } + + @Test + @DisplayName("asset exists, logo desired but server has no logo URI yet — patchAsset IS called") + void upsertAsset_assetExistsLogoDesiredButNoServerUri_patchCalled() { + // Arrange — logo configured but server has no URI yet (logo never uploaded) + com.backbase.stream.investment.Asset req = buildAsset(); + req.setLogo("apple.png"); + + com.backbase.investment.api.service.v1.model.Asset existingApiAsset = + new com.backbase.investment.api.service.v1.model.Asset() + .isin("ABC123").market("market").currency("USD") + .logo(null); // no logo on server yet + + when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) + .thenReturn(Mono.just(existingApiAsset)); + com.backbase.stream.investment.Asset patchedAsset = buildAsset(); + when(investmentRestAssetUniverseService.patchAsset(eq(existingApiAsset), eq(req), any())) + .thenReturn(Mono.just(patchedAsset)); + when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); + + // Act & Assert — logo desired but none stored → patch IS called + StepVerifier.create(service.upsertAsset(req, Map.of())) + .expectNextMatches(a -> "ABC123".equals(a.getIsin())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService) + .patchAsset(eq(existingApiAsset), eq(req), any()); + } @Test @DisplayName("asset already exists — patchAsset is called and mapped asset returned") - void getOrCreateAsset_assetExists_patchCalledAndMappedReturned() { - // Arrange + void upsertAsset_assetExists_patchCalledAndMappedReturned() { + // Arrange — req carries a name that existingApiAsset does not, so data differs and patch is triggered com.backbase.stream.investment.Asset req = buildAsset(); + req.setName("Updated Name"); // differs from existingApiAsset (null name) → triggers patch com.backbase.investment.api.service.v1.model.Asset existingApiAsset = new com.backbase.investment.api.service.v1.model.Asset() .isin("ABC123") @@ -261,7 +394,7 @@ void getOrCreateAsset_assetExists_patchCalledAndMappedReturned() { when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, null)) + StepVerifier.create(service.upsertAsset(req, null)) .expectNextMatches(a -> "ABC123".equals(a.getIsin()) && "market".equals(a.getMarket()) && "USD".equals(a.getCurrency())) @@ -273,7 +406,7 @@ void getOrCreateAsset_assetExists_patchCalledAndMappedReturned() { @Test @DisplayName("asset not found (404) — createAsset is called and created asset returned") - void getOrCreateAsset_assetNotFound_createCalledAndReturned() { + void upsertAsset_assetNotFound_createCalledAndReturned() { // Arrange com.backbase.stream.investment.Asset req = buildAsset(); com.backbase.stream.investment.Asset created = buildAsset(); @@ -284,7 +417,7 @@ void getOrCreateAsset_assetNotFound_createCalledAndReturned() { .thenReturn(Mono.just(created)); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, Map.of())) + StepVerifier.create(service.upsertAsset(req, Map.of())) .expectNext(created) .verifyComplete(); @@ -298,7 +431,7 @@ void getOrCreateAsset_assetNotFound_createCalledAndReturned() { @Test @DisplayName("non-404 error from getAsset — error propagated") - void getOrCreateAsset_nonNotFoundError_propagated() { + void upsertAsset_nonNotFoundError_propagated() { // Arrange com.backbase.stream.investment.Asset req = buildAsset(); @@ -307,7 +440,7 @@ void getOrCreateAsset_nonNotFoundError_propagated() { when(investmentRestAssetUniverseService.createAsset(any(), any())).thenReturn(Mono.empty()); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, null)) + StepVerifier.create(service.upsertAsset(req, null)) .expectErrorMatches(e -> e instanceof RuntimeException && "API error".equals(e.getMessage())) .verify(); @@ -321,7 +454,7 @@ void getOrCreateAsset_nonNotFoundError_propagated() { @Test @DisplayName("asset not found and createAsset fails — error propagated") - void getOrCreateAsset_notFoundAndCreateFails_errorPropagated() { + void upsertAsset_notFoundAndCreateFails_errorPropagated() { // Arrange com.backbase.stream.investment.Asset req = buildAsset(); @@ -331,14 +464,14 @@ void getOrCreateAsset_notFoundAndCreateFails_errorPropagated() { .thenReturn(Mono.error(new RuntimeException("create failed"))); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, null)) + StepVerifier.create(service.upsertAsset(req, null)) .expectErrorMatches(e -> e instanceof RuntimeException && "create failed".equals(e.getMessage())) .verify(); } @Test @DisplayName("asset not found and createAsset returns empty — completes empty") - void getOrCreateAsset_notFoundAndCreateReturnsEmpty_completesEmpty() { + void upsertAsset_notFoundAndCreateReturnsEmpty_completesEmpty() { // Arrange com.backbase.stream.investment.Asset req = buildAsset(); @@ -348,21 +481,21 @@ void getOrCreateAsset_notFoundAndCreateReturnsEmpty_completesEmpty() { .thenReturn(Mono.empty()); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, null)) + StepVerifier.create(service.upsertAsset(req, null)) .verifyComplete(); } @Test @DisplayName("null asset request — NullPointerException thrown") - void getOrCreateAsset_nullRequest_throwsNullPointerException() { - StepVerifier.create(Mono.defer(() -> service.getOrCreateAsset(null, null))) + void upsertAsset_nullRequest_throwsNullPointerException() { + StepVerifier.create(Mono.defer(() -> service.upsertAsset(null, null))) .expectError(NullPointerException.class) .verify(); } @Test @DisplayName("createAsset fails with WebClientResponseException — error propagated") - void getOrCreateAsset_createFailsWithWebClientException_errorPropagated() { + void upsertAsset_createFailsWithWebClientException_errorPropagated() { // Arrange com.backbase.stream.investment.Asset req = buildAsset(); @@ -372,7 +505,7 @@ void getOrCreateAsset_createFailsWithWebClientException_errorPropagated() { .thenReturn(Mono.error(serverError(500))); // Act & Assert - StepVerifier.create(service.getOrCreateAsset(req, null)) + StepVerifier.create(service.upsertAsset(req, null)) .expectErrorMatches(e -> e instanceof WebClientResponseException && ((WebClientResponseException) e).getStatusCode().value() == 500) .verify(); @@ -399,14 +532,47 @@ void getOrCreateAsset_createFailsWithWebClientException_errorPropagated() { @DisplayName("upsertMarketSpecialDay") class UpsertMarketSpecialDayTests { + @Test + @DisplayName("matching special day exists with identical data — updateMarketSpecialDay is skipped and existing day returned") + void upsertMarketSpecialDay_matchingExistsUnchanged_updateSkipped() { + // Arrange — request and existing record carry identical fields + LocalDate date = LocalDate.of(2025, 12, 25); + UUID existingUuid = UUID.randomUUID(); + MarketSpecialDayRequest request = new MarketSpecialDayRequest() + .date(date) + .market("NYSE") + .description("Christmas") + .sessionStart("09:30") + .sessionEnd("13:00"); + MarketSpecialDay existing = new MarketSpecialDay(existingUuid); + existing.setDate(date); + existing.setMarket("NYSE"); + existing.setDescription("Christmas"); + existing.setSessionStart("09:30"); + existing.setSessionEnd("13:00"); + + when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) + .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); + // createMarketSpecialDay stubbed as the switchIfEmpty argument; should not be subscribed + when(assetUniverseApi.createMarketSpecialDay(any())).thenReturn(Mono.just(new MarketSpecialDay(UUID.randomUUID()))); + + // Act & Assert — existing record returned, no update call made + StepVerifier.create(service.upsertMarketSpecialDay(request)) + .expectNext(existing) + .verifyComplete(); + + verify(assetUniverseApi, never()).updateMarketSpecialDay(any(), any()); + } + @Test @DisplayName("matching special day exists — updateMarketSpecialDay called and updated day returned") void upsertMarketSpecialDay_matchingExists_updateCalledAndReturned() { - // Arrange + // Arrange — existing has a description the request does not; data differs so update is triggered LocalDate date = LocalDate.of(2025, 12, 25); UUID existingUuid = UUID.randomUUID(); MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); MarketSpecialDay existing = buildMarketSpecialDay(existingUuid, "NYSE", date); + existing.setDescription("Old description"); // differs from request (null) → triggers update MarketSpecialDay updated = buildMarketSpecialDay(existingUuid, "NYSE", date); when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) @@ -471,11 +637,12 @@ void upsertMarketSpecialDay_noMatchingMarket_createCalledAndReturned() { @Test @DisplayName("matching special day exists but update fails — error propagated") void upsertMarketSpecialDay_updateFails_errorPropagated() { - // Arrange + // Arrange — existing description differs from request so update is triggered LocalDate date = LocalDate.of(2025, 12, 25); UUID existingUuid = UUID.randomUUID(); MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); MarketSpecialDay existing = buildMarketSpecialDay(existingUuid, "NYSE", date); + existing.setDescription("Old description"); // differs from request (null) → triggers update when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); @@ -530,11 +697,12 @@ void upsertMarketSpecialDay_listApiError_propagated() { @Test @DisplayName("matching special day exists but update fails with WebClientResponseException — error propagated") void upsertMarketSpecialDay_webClientExceptionOnUpdate_propagated() { - // Arrange + // Arrange — existing description differs from request so update is triggered LocalDate date = LocalDate.of(2025, 12, 25); UUID existingUuid = UUID.randomUUID(); MarketSpecialDayRequest request = buildMarketSpecialDayRequest("NYSE", date); MarketSpecialDay existing = buildMarketSpecialDay(existingUuid, "NYSE", date); + existing.setDescription("Old description"); // differs from request (null) → triggers update when(assetUniverseApi.listMarketSpecialDay(date, date, 100, 0)) .thenReturn(Mono.just(buildMarketSpecialDayPage(List.of(existing)))); @@ -693,6 +861,156 @@ void upsertAssetCategory_createFails_errorSwallowedReturnsEmpty() { .verifyComplete(); } + @Test + @DisplayName("existing category unchanged, no imageResource supplied — patchAssetCategory is skipped") + void upsertAssetCategory_existingUnchangedNoImage_patchSkipped() { + // Arrange — mirrors real-world: existing has server uuid, desired has uuid=null. + // No imageResource on desired entry → image check returns unchanged → skip entirely. + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setCode("CERTIFICATE"); + entry.setName("Certificate"); + entry.setOrder(1); + entry.setType("ASSET_TYPE"); + entry.setDescription("Certificates are structured financial instruments."); + // uuid and imageResource intentionally null + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + new com.backbase.investment.api.service.v1.model.AssetCategory(existingUuid); + existingCategory.setCode("CERTIFICATE"); + existingCategory.setName("Certificate"); + existingCategory.setOrder(1); + existingCategory.setType("ASSET_TYPE"); + existingCategory.setDescription("Certificates are structured financial instruments."); + + when(assetUniverseApi.listAssetCategories(eq("CERTIFICATE"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + // Act & Assert — skip path: no patch, uuid set on entry via doOnSuccess + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + assertThat(entry.getUuid()).isEqualTo(existingUuid); + verify(investmentRestAssetUniverseService, never()).patchAssetCategory(any(), any(), any()); + } + + @Test + @DisplayName("existing category unchanged, imageResource filename found in server URI — patchAssetCategory is skipped") + void upsertAssetCategory_existingUnchangedImageFilenameMatches_patchSkipped() { + // Arrange — server returns a signed URI that contains the desired filename. + // isImageUnchanged checks existingUri.toString().contains(desiredFilename). + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setCode("TECH_TITANS"); + entry.setName("Tech titans"); + entry.setOrder(3); + entry.setType("COLLECTION"); + entry.setDescription("Dominant innovators shaping tomorrow's digital economy."); + entry.setImageResource(new org.springframework.core.io.ByteArrayResource("img".getBytes()) { + @Override public String getFilename() { return "tech-titans.png"; } + }); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + new com.backbase.investment.api.service.v1.model.AssetCategory(existingUuid); + existingCategory.setCode("TECH_TITANS"); + existingCategory.setName("Tech titans"); + existingCategory.setOrder(3); + existingCategory.setType("COLLECTION"); + existingCategory.setDescription("Dominant innovators shaping tomorrow's digital economy."); + // URI string contains "tech-titans.png" → filename found → image unchanged + existingCategory.setImage(URI.create( + "http://azurite:10000/account1/asset_categories/images/tech-titans.png" + + "?se=2029-05-25T18%3A10%3A11Z&sp=r&sv=2026-02-06&sr=b&sig=abc123")); + + when(assetUniverseApi.listAssetCategories(eq("TECH_TITANS"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + // Act & Assert — skip: data unchanged AND URI contains desired filename + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + assertThat(entry.getUuid()).isEqualTo(existingUuid); + verify(investmentRestAssetUniverseService, never()).patchAssetCategory(any(), any(), any()); + } + + @Test + @DisplayName("existing category unchanged, imageResource filename NOT in server URI — patchAssetCategory IS called") + void upsertAssetCategory_existingUnchangedButDifferentImageFilename_patchCalled() { + // Arrange — server URI does not contain the desired filename → image changed → patch + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setCode("EQUITY"); + entry.setName("Equities"); + entry.setOrder(1); + entry.setType("ASSET_TYPE"); + entry.setImageResource(new org.springframework.core.io.ByteArrayResource("img".getBytes()) { + @Override public String getFilename() { return "new-logo.png"; } + }); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + new com.backbase.investment.api.service.v1.model.AssetCategory(existingUuid); + existingCategory.setCode("EQUITY"); + existingCategory.setName("Equities"); + existingCategory.setOrder(1); + existingCategory.setType("ASSET_TYPE"); + // URI contains "old-logo.png", NOT "new-logo.png" → patch required + existingCategory.setImage(URI.create( + "http://azurite:10000/account1/asset_categories/images/old-logo.png?se=2029-05-25")); + + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); + when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), any())) + .thenReturn(Mono.just(patchedCategory)); + + // Act & Assert — patch IS called because filename not found in server URI + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).patchAssetCategory(eq(existingUuid), eq(entry), any()); + } + + @Test + @DisplayName("imageResource supplied but server has no image URI yet — patchAssetCategory IS called") + void upsertAssetCategory_imageResourceSuppliedButNoServerImage_patchCalled() { + // Arrange — first run after data fields already exist but image upload was missed + UUID existingUuid = UUID.randomUUID(); + AssetCategoryEntry entry = new AssetCategoryEntry(); + entry.setCode("EQUITY"); + entry.setName("Equities"); + entry.setOrder(1); + entry.setType("ASSET_TYPE"); + entry.setImageResource(new org.springframework.core.io.ByteArrayResource("img".getBytes()) { + @Override public String getFilename() { return "equity.png"; } + }); + + com.backbase.investment.api.service.v1.model.AssetCategory existingCategory = + new com.backbase.investment.api.service.v1.model.AssetCategory(existingUuid); + existingCategory.setCode("EQUITY"); + existingCategory.setName("Equities"); + existingCategory.setOrder(1); + existingCategory.setType("ASSET_TYPE"); + existingCategory.setImage(null); // no image stored on server yet + + when(assetUniverseApi.listAssetCategories(eq("EQUITY"), eq(100), any(), eq(0), any(), any())) + .thenReturn(Mono.just(buildAssetCategoryPage(List.of(existingCategory)))); + + AssetCategory patchedCategory = buildSyncAssetCategory(existingUuid); + when(investmentRestAssetUniverseService.patchAssetCategory(eq(existingUuid), eq(entry), any())) + .thenReturn(Mono.just(patchedCategory)); + + // Act & Assert — patch IS called: image desired but none stored yet + StepVerifier.create(service.upsertAssetCategory(entry)) + .expectNextMatches(result -> existingUuid.equals(result.getUuid())) + .verifyComplete(); + + verify(investmentRestAssetUniverseService).patchAssetCategory(eq(existingUuid), eq(entry), any()); + } + @Test @DisplayName("successful patch — entry uuid is updated to patched category uuid") void upsertAssetCategory_successfulPatch_entryUuidUpdated() { @@ -774,15 +1092,35 @@ void upsertAssetCategoryType_nullRequest_returnsEmpty() { verify(assetUniverseApi, never()).createAssetCategoryType(any()); } + @Test + @DisplayName("matching type exists with identical data — updateAssetCategoryType is skipped and existing type returned") + void upsertAssetCategoryType_matchingExistsUnchanged_updateSkipped() { + // Arrange — request and existing type carry identical code and name + UUID existingUuid = UUID.randomUUID(); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); + + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) + .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); + + // Act & Assert — existing type returned, no update call made + StepVerifier.create(service.upsertAssetCategoryType(request)) + .expectNext(existingType) + .verifyComplete(); + + verify(assetUniverseApi, never()).updateAssetCategoryType(any(), any()); + verify(assetUniverseApi, never()).createAssetCategoryType(any()); + } + @Test @DisplayName("matching type exists — updateAssetCategoryType called and updated type returned") void upsertAssetCategoryType_matchingExists_updateCalledAndReturned() { - // Arrange + // Arrange — request name differs from existingType name so update IS triggered UUID existingUuid = UUID.randomUUID(); - AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector Updated"); AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector Updated", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); AssetCategoryType updated = buildAssetCategoryType(existingUuid, "SECTOR", "Sector Updated"); @@ -868,12 +1206,12 @@ void upsertAssetCategoryType_noMatchingCode_createCalledAndReturned() { @Test @DisplayName("update fails — onErrorResume swallows error, Mono.empty() returned") void upsertAssetCategoryType_updateFails_errorSwallowedReturnsEmpty() { - // Arrange + // Arrange — request name differs from existingType so update IS triggered (then fails) UUID existingUuid = UUID.randomUUID(); - AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector Updated"); AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector Updated", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), request)) .thenReturn(Mono.error(new RuntimeException("update failed"))); @@ -906,12 +1244,12 @@ void upsertAssetCategoryType_createFails_errorPropagated() { @Test @DisplayName("WebClientResponseException on updateAssetCategoryType — swallowed by onErrorResume") void upsertAssetCategoryType_webClientExceptionOnUpdate_swallowedReturnsEmpty() { - // Arrange + // Arrange — request name differs from existingType so update IS triggered (then fails) UUID existingUuid = UUID.randomUUID(); - AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector"); + AssetCategoryTypeRequest request = buildAssetCategoryTypeRequest("SECTOR", "Sector Updated"); AssetCategoryType existingType = buildAssetCategoryType(existingUuid, "SECTOR", "Sector"); - when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector", 0)) + when(assetUniverseApi.listAssetCategoryTypes("SECTOR", 100, "Sector Updated", 0)) .thenReturn(Mono.just(buildAssetCategoryTypePage(List.of(existingType)))); when(assetUniverseApi.updateAssetCategoryType(existingUuid.toString(), request)) .thenReturn(Mono.error(serverError(500))); @@ -924,28 +1262,28 @@ void upsertAssetCategoryType_webClientExceptionOnUpdate_swallowedReturnsEmpty() } // ========================================================================= - // createAssets + // upsertAssets // ========================================================================= /** - * Tests for {@link InvestmentAssetUniverseService#createAssets(List)}. + * Tests for {@link InvestmentAssetUniverseService#upsertAssets(List)}. * *

Covers: *

    *
  • Null list → returns Flux.empty() without calling API
  • *
  • Empty list → returns Flux.empty() without calling API
  • - *
  • Non-empty list → listAssetCategories called and each asset processed via getOrCreateAsset
  • + *
  • Non-empty list → listAssetCategories called and each asset processed via upsertAsset
  • *
*/ @Nested - @DisplayName("createAssets") - class CreateAssetsTests { + @DisplayName("upsertAssets") + class UpsertAssetsTests { @Test @DisplayName("null asset list — returns empty Flux without calling any API") - void createAssets_nullList_returnsEmptyFlux() { + void upsertAssets_nullList_returnsEmptyFlux() { // Act & Assert - StepVerifier.create(service.createAssets(null)) + StepVerifier.create(service.upsertAssets(null)) .verifyComplete(); verify(assetUniverseApi, never()).listAssetCategories(any(), any(), any(), any(), any(), any()); @@ -953,9 +1291,9 @@ void createAssets_nullList_returnsEmptyFlux() { @Test @DisplayName("empty asset list — returns empty Flux without calling any API") - void createAssets_emptyList_returnsEmptyFlux() { + void upsertAssets_emptyList_returnsEmptyFlux() { // Act & Assert - StepVerifier.create(service.createAssets(List.of())) + StepVerifier.create(service.upsertAssets(List.of())) .verifyComplete(); verify(assetUniverseApi, never()).listAssetCategories(any(), any(), any(), any(), any(), any()); @@ -963,7 +1301,7 @@ void createAssets_emptyList_returnsEmptyFlux() { @Test @DisplayName("non-empty list — listAssetCategories called and each asset processed") - void createAssets_nonEmptyList_listCategoriesCalledAndAssetsProcessed() { + void upsertAssets_nonEmptyList_listCategoriesCalledAndAssetsProcessed() { // Arrange com.backbase.stream.investment.Asset assetReq = buildAsset(); com.backbase.stream.investment.Asset created = buildAsset(); @@ -976,7 +1314,7 @@ void createAssets_nonEmptyList_listCategoriesCalledAndAssetsProcessed() { .thenReturn(Mono.just(created)); // Act & Assert - StepVerifier.create(service.createAssets(List.of(assetReq))) + StepVerifier.create(service.upsertAssets(List.of(assetReq))) .expectNextCount(1) .verifyComplete(); @@ -985,7 +1323,7 @@ void createAssets_nonEmptyList_listCategoriesCalledAndAssetsProcessed() { @Test @DisplayName("duplicate asset keys in input — deduplicated, only one asset processed") - void createAssets_duplicateAssetKeys_deduplicatedAndProcessedOnce() { + void upsertAssets_duplicateAssetKeys_deduplicatedAndProcessedOnce() { // Arrange — two distinct instances with the same isin+market+currency key com.backbase.stream.investment.Asset asset1 = buildAsset(); // key: ABC123_market_USD com.backbase.stream.investment.Asset asset2 = buildAsset(); // same key @@ -998,7 +1336,7 @@ void createAssets_duplicateAssetKeys_deduplicatedAndProcessedOnce() { .thenReturn(Mono.just(asset1)); // Act & Assert — only one element emitted despite two inputs - StepVerifier.create(service.createAssets(List.of(asset1, asset2))) + StepVerifier.create(service.upsertAssets(List.of(asset1, asset2))) .expectNextCount(1) .verifyComplete(); @@ -1008,7 +1346,7 @@ void createAssets_duplicateAssetKeys_deduplicatedAndProcessedOnce() { @Test @DisplayName("multiple distinct assets — all assets processed and emitted") - void createAssets_multipleDistinctAssets_allAssetsProcessed() { + void upsertAssets_multipleDistinctAssets_allAssetsProcessed() { // Arrange com.backbase.stream.investment.Asset assetA = buildAsset("ISINA", "XNAS", "USD"); com.backbase.stream.investment.Asset assetB = buildAsset("ISINB", "XAMS", "EUR"); @@ -1025,7 +1363,7 @@ void createAssets_multipleDistinctAssets_allAssetsProcessed() { .thenReturn(Mono.just(assetB)); // Act & Assert - StepVerifier.create(service.createAssets(List.of(assetA, assetB))) + StepVerifier.create(service.upsertAssets(List.of(assetA, assetB))) .expectNextCount(2) .verifyComplete(); @@ -1034,7 +1372,7 @@ void createAssets_multipleDistinctAssets_allAssetsProcessed() { @Test @DisplayName("listAssetCategories returns page with null results — empty Flux returned without processing assets") - void createAssets_listCategoriesReturnsNullResults_returnsEmptyFlux() { + void upsertAssets_listCategoriesReturnsNullResults_returnsEmptyFlux() { // Arrange — the second filter(Objects::nonNull) in the chain stops execution when results is null com.backbase.stream.investment.Asset assetReq = buildAsset(); PaginatedAssetCategoryList nullResultPage = new PaginatedAssetCategoryList(); @@ -1044,17 +1382,18 @@ void createAssets_listCategoriesReturnsNullResults_returnsEmptyFlux() { .thenReturn(Mono.just(nullResultPage)); // Act & Assert - StepVerifier.create(service.createAssets(List.of(assetReq))) + StepVerifier.create(service.upsertAssets(List.of(assetReq))) .verifyComplete(); verify(investmentRestAssetUniverseService, never()).createAsset(any(), any()); } @Test - @DisplayName("asset already exists — patchAsset called within createAssets, existing asset returned") - void createAssets_assetAlreadyExists_patchCalledAndReturned() { - // Arrange + @DisplayName("asset already exists — patchAsset called within upsertAssets, existing asset returned") + void upsertAssets_assetAlreadyExists_patchCalledAndReturned() { + // Arrange — req carries a name that existingApiAsset does not, so data differs and patch is triggered com.backbase.stream.investment.Asset req = buildAsset(); + req.setName("Updated Name"); // differs from existingApiAsset (null name) → triggers patch com.backbase.investment.api.service.v1.model.Asset existingApiAsset = new com.backbase.investment.api.service.v1.model.Asset() .isin("ABC123") @@ -1065,12 +1404,12 @@ void createAssets_assetAlreadyExists_patchCalledAndReturned() { .thenReturn(Mono.just(buildAssetCategoryPage(List.of()))); when(assetUniverseApi.getAsset("ABC123_market_USD", null, null, null)) .thenReturn(Mono.just(existingApiAsset)); - // patchAsset is called for its side-effect; the result is replaced by the existing API asset + // patchAsset returns the desired stream asset; verify it is what gets emitted when(investmentRestAssetUniverseService.patchAsset(eq(existingApiAsset), eq(req), any())) .thenReturn(Mono.just(req)); - // Act & Assert — existing asset is mapped and emitted - StepVerifier.create(service.createAssets(List.of(req))) + // Act & Assert — patched asset (req) is emitted + StepVerifier.create(service.upsertAssets(List.of(req))) .expectNextMatches(a -> "ABC123".equals(a.getIsin()) && "market".equals(a.getMarket()) && "USD".equals(a.getCurrency())) @@ -1230,5 +1569,3 @@ private PaginatedAssetCategoryTypeList buildAssetCategoryTypePage(List result.size() == 1 && "EUR".equals(result.get(0).getCode())) + .verifyComplete(); + + verify(currencyApi, never()).updateCurrency(any(), any()); + verify(currencyApi, never()).createCurrency(any()); + } + + @Test + @DisplayName("currency already exists — updateCurrency is called and currency returned") + void upsertCurrencies_currencyAlreadyExists_updateCurrencyCalledAndReturned() { + // Arrange — desired name differs from existing name so update IS triggered + Currency currency = buildCurrency("EUR", "Euro Updated", "€"); Currency existingEntry = buildCurrency("EUR", "Euro", "€"); PaginatedCurrencyList page = buildPage(List.of(existingEntry)); @@ -141,18 +160,18 @@ void upsertCurrencies_currencyAlreadyExists_updateCurrencyCalledAndReturned() { verify(currencyApi).updateCurrency(eq("EUR"), captor.capture()); verify(currencyApi, never()).createCurrency(any()); assertThat(captor.getValue().getCode()).isEqualTo("EUR"); - assertThat(captor.getValue().getName()).isEqualTo("Euro"); + assertThat(captor.getValue().getName()).isEqualTo("Euro Updated"); assertThat(captor.getValue().getSymbol()).isEqualTo("€"); } @Test @DisplayName("multiple currencies — mix of create and update, both results collected") void upsertCurrencies_multipleCurrencies_mixedCreateAndUpdate_allCollected() { - // Arrange + // Arrange — eurCurrency has a new name; existingEur has old name → update triggered for EUR Currency usdCurrency = buildCurrency("USD", "US Dollar", "$"); - Currency eurCurrency = buildCurrency("EUR", "Euro", "€"); + Currency eurCurrency = buildCurrency("EUR", "Euro Updated", "€"); - // EUR already exists, USD does not + // EUR already exists (old name), USD does not Currency existingEur = buildCurrency("EUR", "Euro", "€"); PaginatedCurrencyList page = buildPage(List.of(existingEur)); when(currencyApi.listCurrencies(InvestmentCurrencyService.CONTENT_RETRIEVE_LIMIT, 0)) @@ -247,8 +266,8 @@ void upsertCurrencies_createCurrencyFails_errorSwallowed_entryAbsentFromResult() @Test @DisplayName("updateCurrency fails — onErrorResume swallows error, entry absent from result") void upsertCurrencies_updateCurrencyFails_errorSwallowed_entryAbsentFromResult() { - // Arrange - Currency currency = buildCurrency("CHF", "Swiss Franc", "Fr"); + // Arrange — desired name differs from existing so update IS triggered (then fails and is swallowed) + Currency currency = buildCurrency("CHF", "Swiss Franc Updated", "Fr"); Currency existingEntry = buildCurrency("CHF", "Swiss Franc", "Fr"); PaginatedCurrencyList page = buildPage(List.of(existingEntry));