From 88f0363fae266c74537b6c12ac9f744b7c7eb578 Mon Sep 17 00:00:00 2001 From: HOYA <66549638+chominho96@users.noreply.github.com> Date: Sun, 21 Apr 2024 13:15:21 +0900 Subject: [PATCH] feat: implement portfolio api (#93) * chore: remove unnecessary import * feat: add portfolio command service * feat: add portfolio query service * feat: add portfolio controller * docs: add portfolio controller docs * fix: remove portfolio command service * test: add test for create portfolio api * feat: add monthly/yearly dividend api * feat: add monthly/yearly dividend api * docs: add swagger docs * feat: implement dividend repository custom * test: add portfolio query service test * test: add portfolio controller test * feat: add sector-ratio service * feat: update portfolio controller * test: add test code * test: add service test code * feat: update swagger docs --------- Co-authored-by: Songyi Kim --- .../application/PortfolioQueryService.java | 152 +++++++++ .../dto/request/PortfolioRequest.java | 15 + .../application/dto/request/TickerShare.java | 14 + .../dto/response/MonthlyDividendResponse.java | 32 ++ .../dto/response/PortfolioResponse.java | 11 + .../dto/response/SectorRatioResponse.java | 37 +++ .../SingleMonthlyDividendResponse.java | 28 ++ .../SingleYearlyDividendResponse.java | 24 ++ .../dto/response/YearlyDividendResponse.java | 28 ++ .../presentation/PortfolioController.java | 46 +++ .../presentation/PortfolioControllerDocs.java | 87 +++++ .../dividend/common/IntegrationTest.java | 36 ++ .../presentation/DividendControllerTest.java | 2 +- .../PortfolioQueryServiceTest.java | 199 +++++++++++ .../portfolio/common/GivenFixtureTest.java | 94 ++++++ .../portfolio/common/IntegrationTest.java | 41 +++ .../presentation/PortfolioControllerTest.java | 310 ++++++++++++++++++ .../payout/core/time/InstantProvider.java | 4 + .../infra/DividendRepositoryCustom.java | 2 + .../infra/DividendRepositoryImpl.java | 25 ++ .../domain/portfolio/domain/Portfolio.java | 14 +- .../portfolio/domain/PortfolioStocks.java | 2 +- .../exception/PortfolioNotFoundException.java | 12 + .../exception/StockIdNotFoundException.java | 12 + .../payout/domain/PortfolioFixture.java | 10 +- 25 files changed, 1231 insertions(+), 6 deletions(-) create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java create mode 100644 api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java create mode 100644 api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java create mode 100644 api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java create mode 100644 api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java create mode 100644 api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java create mode 100644 api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java create mode 100644 domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java create mode 100644 domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java new file mode 100644 index 00000000..9da807ad --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryService.java @@ -0,0 +1,152 @@ +package nexters.payout.apiserver.portfolio.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.*; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.portfolio.domain.exception.PortfolioNotFoundException; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.exception.StockIdNotFoundException; +import nexters.payout.domain.stock.domain.exception.TickerNotFoundException; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.StockShare; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class PortfolioQueryService { + + private final StockRepository stockRepository; + private final PortfolioRepository portfolioRepository; + private final DividendRepository dividendRepository; + private final SectorAnalysisService sectorAnalysisService; + + public PortfolioResponse createPortfolio(final PortfolioRequest request) { + + List portfolioStocks = + request.tickerShares() + .stream() + .map(tickerShare -> new PortfolioStock( + getStockByTicker(tickerShare.ticker()).getId(), + tickerShare.share()) + ) + .toList(); + + return new PortfolioResponse( + portfolioRepository.save(new Portfolio(InstantProvider.getExpireAt(), portfolioStocks)) + .getId() + ); + } + + public List analyzeSectorRatio(final UUID portfolioId) { + List portfolioStocks = getPortfolio(portfolioId).portfolioStocks(); + List stockShares = portfolioStocks + .stream() + .map(ps -> new StockShare(getStock(ps.getStockId()), ps.getShares())) + .toList(); + Map sectorInfoMap = sectorAnalysisService.calculateSectorRatios(stockShares); + + return SectorRatioResponse.fromMap(sectorInfoMap); + } + + @Transactional(readOnly = true) + public List getMonthlyDividends(final UUID id) { + return InstantProvider.generateNext12Months() + .stream() + .map(yearMonth -> MonthlyDividendResponse.of( + yearMonth.getYear(), + yearMonth.getMonthValue(), + getDividendsOfLastYearAndMonth( + getPortfolio(id).portfolioStocks(), + yearMonth.getMonthValue() + ) + ) + ) + .collect(Collectors.toList()); + } + + private Stock getStockByTicker(String ticker) { + return stockRepository.findByTicker(ticker) + .orElseThrow(() -> new TickerNotFoundException(ticker)); + } + + private Stock getStock(UUID stockId) { + return stockRepository.findById(stockId).orElseThrow(() -> new StockIdNotFoundException(stockId)); + } + + private Portfolio getPortfolio(UUID id) { + return portfolioRepository.findById(id) + .orElseThrow(() -> new PortfolioNotFoundException(id)); + } + + @Transactional(readOnly = true) + public YearlyDividendResponse getYearlyDividends(final UUID id) { + + List dividends = getPortfolio(id) + .portfolioStocks() + .stream() + .map(portfolioStock -> { + Stock stock = getStock(portfolioStock.getStockId()); + return SingleYearlyDividendResponse.of( + stock, portfolioStock.getShares(), getYearlyDividend(stock.getId()) + ); + }) + .filter(response -> response.totalDividend() != 0) + .toList(); + + return YearlyDividendResponse.of(dividends); + } + + private double getYearlyDividend(final UUID stockId) { + return getLastYearDividendsByStockId(stockId) + .stream() + .mapToDouble(Dividend::getDividend) + .sum(); + } + + private List getLastYearDividendsByStockId(final UUID id) { + return dividendRepository.findAllByIdAndYear(id, InstantProvider.getLastYear()); + } + + private List getDividendsOfLastYearAndMonth( + final List portfolioStocks, final int month + ) { + return portfolioStocks + .stream() + .flatMap(portfolioStock -> stockRepository.findById(portfolioStock.getStockId()) + .map(stock -> getMonthlyDividendResponse(month, portfolioStock, stock)) + .orElseThrow(() -> new StockIdNotFoundException(portfolioStock.getStockId()))) + .toList(); + } + + private Stream getMonthlyDividendResponse( + final int month, final PortfolioStock portfolioStock, final Stock stock + ) { + return getLastYearDividendsByStockIdAndMonth(portfolioStock.getStockId(), month) + .stream() + .map(dividend -> SingleMonthlyDividendResponse.of(stock, portfolioStock.getShares(), dividend)); + } + + private List getLastYearDividendsByStockIdAndMonth(final UUID stockId, final int month) { + return dividendRepository.findAllByIdAndYearAndMonth(stockId, InstantProvider.getLastYear(), month); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java new file mode 100644 index 00000000..612a9bbb --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/PortfolioRequest.java @@ -0,0 +1,15 @@ +package nexters.payout.apiserver.portfolio.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PortfolioRequest( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @Size(min = 1) + List tickerShares +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java new file mode 100644 index 00000000..dbc28c16 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/request/TickerShare.java @@ -0,0 +1,14 @@ +package nexters.payout.apiserver.portfolio.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; + +public record TickerShare( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "ticker name") + @NotEmpty + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "share") + @Min(value = 1) + Integer share +) { } \ No newline at end of file diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java new file mode 100644 index 00000000..05ab9d45 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/MonthlyDividendResponse.java @@ -0,0 +1,32 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Comparator; +import java.util.List; + +public record MonthlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer year, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer month, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static MonthlyDividendResponse of( + final int year, final int month, final List dividends + ) { + return new MonthlyDividendResponse( + year, + month, + dividends.stream() + .sorted(Comparator.comparingDouble(SingleMonthlyDividendResponse::totalDividend).reversed()) + .toList(), + dividends.stream() + .mapToDouble(SingleMonthlyDividendResponse::totalDividend) + .sum() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java new file mode 100644 index 00000000..5a213168 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/PortfolioResponse.java @@ -0,0 +1,11 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +public record PortfolioResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID id +) { +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java new file mode 100644 index 00000000..5ba5a36d --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SectorRatioResponse.java @@ -0,0 +1,37 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.apiserver.stock.application.dto.response.StockShareResponse; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService.SectorInfo; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record SectorRatioResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector name") + String sectorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector value") + String sectorValue, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "sector ratio") + Double sectorRatio, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List stockShares +) { + public static List fromMap(final Map sectorRatioMap) { + return sectorRatioMap.entrySet() + .stream() + .map(entry -> new SectorRatioResponse( + entry.getKey().getName(), + entry.getKey().name(), + entry.getValue().ratio(), + entry.getValue() + .stockShares() + .stream() + .map(StockShareResponse::from) + .collect(Collectors.toList())) + ) + .collect(Collectors.toList()); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java new file mode 100644 index 00000000..1dee85c7 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleMonthlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.stock.domain.Stock; + +public record SingleMonthlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer share, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double dividend, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static SingleMonthlyDividendResponse of(Stock stock, int share, Dividend dividend) { + return new SingleMonthlyDividendResponse( + stock.getTicker(), + stock.getLogoUrl(), + share, + dividend.getDividend(), + dividend.getDividend() * share + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java new file mode 100644 index 00000000..6bb7d1b9 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/SingleYearlyDividendResponse.java @@ -0,0 +1,24 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import nexters.payout.domain.stock.domain.Stock; + +public record SingleYearlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String ticker, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String logoUrl, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer share, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static SingleYearlyDividendResponse of(Stock stock, int share, double dividend) { + return new SingleYearlyDividendResponse( + stock.getTicker(), + stock.getLogoUrl(), + share, + dividend * share + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java new file mode 100644 index 00000000..d25a10a6 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/application/dto/response/YearlyDividendResponse.java @@ -0,0 +1,28 @@ +package nexters.payout.apiserver.portfolio.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Comparator; +import java.util.List; + +public record YearlyDividendResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List dividends, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Double totalDividend +) { + public static YearlyDividendResponse of(List dividends) { + + dividends = dividends + .stream() + .sorted(Comparator.comparingDouble(SingleYearlyDividendResponse::totalDividend).reversed()) + .toList(); + return new YearlyDividendResponse( + dividends, + dividends + .stream() + .mapToDouble(SingleYearlyDividendResponse::totalDividend) + .sum() + ); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java new file mode 100644 index 00000000..ccde91dc --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioController.java @@ -0,0 +1,46 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nexters.payout.apiserver.portfolio.application.PortfolioQueryService; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.stock.application.dto.request.SectorRatioRequest; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/portfolios") +@Slf4j +public class PortfolioController implements PortfolioControllerDocs { + + private final PortfolioQueryService portfolioQueryService; + + @PostMapping + public ResponseEntity createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest) { + return ResponseEntity.ok(portfolioQueryService.createPortfolio(portfolioRequest)); + } + + @GetMapping("/{id}/monthly") + public ResponseEntity> getMonthlyDividends(@PathVariable("id") final UUID portfolioId) { + return ResponseEntity.ok(portfolioQueryService.getMonthlyDividends(portfolioId)); + } + + @GetMapping("/{id}/yearly") + public ResponseEntity getYearlyDividends(@PathVariable("id") final UUID portfolioId) { + return ResponseEntity.ok(portfolioQueryService.getYearlyDividends(portfolioId)); + } + + @GetMapping("/{id}/sector-ratio") + public ResponseEntity> getSectorRatios(@PathVariable("id") final UUID portfolioId) { + return ResponseEntity.ok(portfolioQueryService.analyzeSectorRatio(portfolioId)); + } +} diff --git a/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java new file mode 100644 index 00000000..c31ccd70 --- /dev/null +++ b/api-server/src/main/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerDocs.java @@ -0,0 +1,87 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.core.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; +import java.util.UUID; + + +public interface PortfolioControllerDocs { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 생성", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = PortfolioRequest.class), + examples = { + @ExampleObject(name = "PortfolioRequestExample", value = "{\"tickerShares\":[{\"ticker\":\"AAPL\",\"share\":3}]}") + }))) + ResponseEntity createPortfolio(@RequestBody @Valid final PortfolioRequest portfolioRequest); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 기반 월간 배당금 조회") + ResponseEntity> getMonthlyDividends( + @Parameter(description = "portfolio id", example = "bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb", required = true) + @PathVariable("id") final UUID portfolioId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "포트폴리오 기반 연간 배당금 조회") + ResponseEntity getYearlyDividends( + @Parameter(description = "portfolio id", example = "bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb", required = true) + @PathVariable("id") final UUID portfolioId + ); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SUCCESS"), + @ApiResponse(responseCode = "400", description = "BAD REQUEST", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "404", description = "NOT FOUND", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse(responseCode = "500", description = "SERVER ERROR", + content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + }) + @Operation(summary = "섹터 비중 분석") + ResponseEntity> getSectorRatios(@PathVariable("id") final UUID portfolioId); +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java new file mode 100644 index 00000000..f39d3852 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/common/IntegrationTest.java @@ -0,0 +1,36 @@ +package nexters.payout.apiserver.dividend.common; + +import io.restassured.RestAssured; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + public StockRepository stockRepository; + + @Autowired + public DividendRepository dividendRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void afterEach() { + dividendRepository.deleteAll(); + stockRepository.deleteAll(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java index d86b91f6..dd9a50f2 100644 --- a/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java +++ b/api-server/src/test/java/nexters/payout/apiserver/dividend/presentation/DividendControllerTest.java @@ -7,7 +7,7 @@ import nexters.payout.apiserver.dividend.application.dto.request.TickerShare; import nexters.payout.apiserver.dividend.application.dto.response.MonthlyDividendResponse; import nexters.payout.apiserver.dividend.application.dto.response.YearlyDividendResponse; -import nexters.payout.apiserver.stock.common.IntegrationTest; +import nexters.payout.apiserver.dividend.common.IntegrationTest; import nexters.payout.core.exception.ErrorResponse; import nexters.payout.core.time.InstantProvider; import nexters.payout.domain.DividendFixture; diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java new file mode 100644 index 00000000..cc9c8bb1 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/application/PortfolioQueryServiceTest.java @@ -0,0 +1,199 @@ +package nexters.payout.apiserver.portfolio.application; + +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.request.TickerShare; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.PortfolioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.portfolio.common.GivenFixtureTest; +import nexters.payout.apiserver.stock.application.dto.response.SectorRatioResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockResponse; +import nexters.payout.apiserver.stock.application.dto.response.StockShareResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.service.SectorAnalysisService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static nexters.payout.domain.PortfolioFixture.createPortfolio; +import static nexters.payout.domain.StockFixture.*; +import static nexters.payout.domain.stock.domain.Sector.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class PortfolioQueryServiceTest extends GivenFixtureTest { + + @Mock + private PortfolioRepository portfolioRepository; + + @InjectMocks + private PortfolioQueryService portfolioQueryService; + + @Spy + private SectorAnalysisService sectorAnalysisService; + + @Test + void 포트폴리오를_생성한다() { + // given + Stock appl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 4.0); + Stock tsla = StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, 2.2); + given(stockRepository.findByTicker(eq(AAPL))).willReturn(Optional.of(appl)); + given(stockRepository.findByTicker(eq(TSLA))).willReturn(Optional.of(tsla)); + + given(portfolioRepository.save(any())).willReturn(createPortfolio( + UUID.fromString("67221662-c2f7-4f35-9447-6a65ca88d5ea"), + InstantProvider.getExpireAt(), + List.of( + new PortfolioStock(UUID.randomUUID(), 2), + new PortfolioStock(UUID.randomUUID(), 1) + ) + ) + ); + String expected = "67221662-c2f7-4f35-9447-6a65ca88d5ea"; + + // when + PortfolioResponse actual = portfolioQueryService.createPortfolio(request()); + + // then + assertThat(actual.id()).isEqualTo(UUID.fromString(expected)); + } + + @Test + void 섹터_정보를_정상적으로_반환한다() { + // given + Stock aapl = StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 2.0); + UUID portfolioId = UUID.randomUUID(); + given(portfolioRepository.findById(portfolioId)).willReturn(Optional.of( + createPortfolio( + List.of(new PortfolioStock(aapl.getId(), 2)) + )) + ); + given(stockRepository.findById(any())).willReturn(Optional.of(aapl)); + + List expected = List.of( + new SectorRatioResponse( + Sector.TECHNOLOGY.getName(), + Sector.TECHNOLOGY.name(), + 1.0, List.of(new StockShareResponse(StockResponse.from(aapl), 2)) + ) + ); + + // when + List actual = portfolioQueryService.analyzeSectorRatio(portfolioId); + + // then + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + } + + + @Test + void 사용자의_월간_배당금_정보를_가져온다() { + // given + UUID id = UUID.fromString("bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb"); + givenPortfolioForMonthlyDividend(id); + double expected = 86.8; + + // when + List actual = portfolioQueryService.getMonthlyDividends(id); + + // then + assertAll( + () -> assertThat(actual.size()).isEqualTo(12), + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()).isEqualTo(expected), + () -> assertThat(actual.get(11).dividends().get(0).totalDividend()).isEqualTo(5.0) + ); + } + + @Test + void 사용자의_연간_배당금_정보를_가져온다() { + // given + UUID id = UUID.fromString("bf5ffb6d-ae70-4171-8c86-b27c8ab2efbb"); + givenPortfolioForYearlyDividend(id); + double totalDividendExpected = 86.8; + double aaplDividendExpected = 60.0; + + // when + YearlyDividendResponse actual = portfolioQueryService.getYearlyDividends(id); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(totalDividendExpected), + () -> assertThat(actual.dividends() + .stream() + .filter(dividend -> dividend.ticker().equals(AAPL)) + .findFirst().get() + .totalDividend()) + .isEqualTo(aaplDividendExpected) + ); + } + + private void givenPortfolioForMonthlyDividend(UUID id) { + Stock aapl = givenStockAndDividendForMonthly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + Stock tsla = givenStockAndDividendForMonthly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + Stock sbux = givenStockAndDividendForMonthly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + + List portfolioStocks = new ArrayList<>(); + + portfolioStocks.add(new PortfolioStock(aapl.getId(), 2)); + portfolioStocks.add(new PortfolioStock(tsla.getId(), 1)); + portfolioStocks.add(new PortfolioStock(sbux.getId(), 1)); + + Portfolio portfolio = createPortfolio( + id, + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + portfolioStocks + ); + + given(portfolioRepository.findById(eq(id))).willReturn(Optional.of(portfolio)); + } + + private void givenPortfolioForYearlyDividend(UUID id) { + Stock aapl = givenStockAndDividendForYearly(AAPL, TECHNOLOGY, 2.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + Stock tsla = givenStockAndDividendForYearly(TSLA, UTILITIES, 4.2, 1, 4, 7, 10); + Stock sbux = givenStockAndDividendForYearly(SBUX, CONSUMER_CYCLICAL, 5.0, 6, 12); + + List portfolioStocks = new ArrayList<>(); + + portfolioStocks.add(new PortfolioStock(aapl.getId(), 2)); + portfolioStocks.add(new PortfolioStock(tsla.getId(), 1)); + portfolioStocks.add(new PortfolioStock(sbux.getId(), 1)); + + Portfolio portfolio = createPortfolio( + id, + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + portfolioStocks + ); + + given(portfolioRepository.findById(eq(id))).willReturn(Optional.of(portfolio)); + } + + private PortfolioRequest request() { + return new PortfolioRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 1)) + ); + } +} \ No newline at end of file diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java new file mode 100644 index 00000000..d55b3496 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/GivenFixtureTest.java @@ -0,0 +1,94 @@ +package nexters.payout.apiserver.portfolio.common; + +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.dividend.domain.Dividend; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.*; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public abstract class GivenFixtureTest { + + private final Integer JANUARY = 1; + private final Integer DECEMBER = 12; + + @Mock + protected DividendRepository dividendRepository; + + @Mock + protected StockRepository stockRepository; + + public Stock givenStockAndDividendForMonthly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findById(eq(stock.getId()))).willReturn(Optional.of(stock)); + + for (int month = JANUARY; month <= DECEMBER; month++) { + if (isContain(cycle, month)) { + // 배당 주기에 해당하는 경우 + given(dividendRepository.findAllByIdAndYearAndMonth( + eq(stock.getId()), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(List.of(DividendFixture.createDividend( + stock.getId(), + dividend, + parseDate(InstantProvider.getLastYear(), month) + ))); + } else { + // 배당 주기에 해당하지 않는 경우 + given(dividendRepository.findAllByIdAndYearAndMonth( + eq(stock.getId()), + eq(InstantProvider.getLastYear()), + eq(month))) + .willReturn(new ArrayList<>()); + } + } + + return stock; + } + + public Stock givenStockAndDividendForYearly(String ticker, Sector sector, double dividend, int... cycle) { + Stock stock = StockFixture.createStock(ticker, sector); + given(stockRepository.findById(eq(stock.getId()))).willReturn(Optional.of(stock)); + + List dividends = new ArrayList<>(); + for (int month : cycle) { + dividends.add(DividendFixture.createDividend( + stock.getId(), + dividend, + parseDate(InstantProvider.getLastYear(), month))); + } + + given(dividendRepository.findAllByIdAndYear( + eq(stock.getId()), + eq(InstantProvider.getLastYear()))) + .willReturn(dividends); + + return stock; + } + + private boolean isContain(int[] cycle, int month) { + return Arrays.stream(cycle).anyMatch(m -> m == month); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java new file mode 100644 index 00000000..148c4a5c --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/common/IntegrationTest.java @@ -0,0 +1,41 @@ +package nexters.payout.apiserver.portfolio.common; + +import io.restassured.RestAssured; +import nexters.payout.domain.dividend.domain.repository.DividendRepository; +import nexters.payout.domain.portfolio.domain.repository.PortfolioRepository; +import nexters.payout.domain.stock.domain.repository.StockRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public abstract class IntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + public StockRepository stockRepository; + + @Autowired + public DividendRepository dividendRepository; + + @Autowired + public PortfolioRepository portfolioRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void afterEach() { + dividendRepository.deleteAll(); + stockRepository.deleteAll(); + portfolioRepository.deleteAll(); + } +} diff --git a/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java b/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java new file mode 100644 index 00000000..6231ff09 --- /dev/null +++ b/api-server/src/test/java/nexters/payout/apiserver/portfolio/presentation/PortfolioControllerTest.java @@ -0,0 +1,310 @@ +package nexters.payout.apiserver.portfolio.presentation; + +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import nexters.payout.apiserver.portfolio.application.dto.response.MonthlyDividendResponse; +import nexters.payout.apiserver.portfolio.application.dto.request.PortfolioRequest; +import nexters.payout.apiserver.portfolio.application.dto.request.TickerShare; +import nexters.payout.apiserver.portfolio.application.dto.response.SectorRatioResponse; +import nexters.payout.apiserver.portfolio.application.dto.response.YearlyDividendResponse; +import nexters.payout.apiserver.portfolio.common.IntegrationTest; +import nexters.payout.core.exception.ErrorResponse; +import nexters.payout.core.time.InstantProvider; +import nexters.payout.domain.DividendFixture; +import nexters.payout.domain.PortfolioFixture; +import nexters.payout.domain.StockFixture; +import nexters.payout.domain.portfolio.domain.Portfolio; +import nexters.payout.domain.portfolio.domain.PortfolioStock; +import nexters.payout.domain.stock.domain.Sector; +import nexters.payout.domain.stock.domain.Stock; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Test; + +import java.time.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static nexters.payout.domain.StockFixture.AAPL; +import static nexters.payout.domain.StockFixture.TSLA; +import static org.apache.http.HttpStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +public class PortfolioControllerTest extends IntegrationTest { + + @Test + void 포트폴리오_생성시_티커를_찾을수_없는경우_404_예외가_발생한다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_NOT_FOUND) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시_빈_리스트로_요청한_경우_400_예외가_발생한다() { + // given + PortfolioRequest request = new PortfolioRequest(new ArrayList<>()); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시_티커가_빈문자열이면_400_예외가_발생한다() { + // given + PortfolioRequest request = new PortfolioRequest(List.of(new TickerShare("", 2))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 포트폴리오_생성시__종목_소유_개수가_0개인_경우_400_예외가_발생한다() { + // given + stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + PortfolioRequest request = new PortfolioRequest(List.of(new TickerShare(TSLA, 0))); + + // when, then + RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("api/portfolios") + .then().log().all() + .statusCode(SC_BAD_REQUEST) + .extract() + .as(ErrorResponse.class); + } + + @Test + void 사용자의_섹터_비중을_분석한다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL, 10.0)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY, 20.0)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + List.of(new PortfolioStock(tsla.getId(), 1), new PortfolioStock(aapl.getId(), 1)) + ) + ); + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .body(request()) + .when().get(String.format("api/portfolios/%s/sector-ratio", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + List sorted = actual.stream() + .sorted(Comparator.comparing(SectorRatioResponse::sectorRatio)) + .toList(); + // then + assertAll( + () -> assertThat(sorted).hasSize(2), + () -> assertThat(sorted.get(0).sectorRatio()).isCloseTo(0.33, Offset.offset(0.01)), + () -> assertThat(sorted.get(0).sectorName()).isEqualTo(Sector.CONSUMER_CYCLICAL.getName()), + () -> assertThat(sorted.get(1).sectorRatio()).isCloseTo(0.66, Offset.offset(0.01)), + () -> assertThat(sorted.get(1).sectorName()).isEqualTo(Sector.TECHNOLOGY.getName()) + ); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하지_않는_경우_정상적으로_조회된다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(tsla.getId(), 2), new PortfolioStock(aapl.getId(), 1)) + ) + ); + double expected = 0.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/monthly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()) + .isEqualTo(expected), + () -> actual.forEach(res -> assertThat(res.dividends()).isEmpty()) + ); + } + + @Test + void 월별_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + double expected = 13.0; + + // when + List actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/monthly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual + .stream() + .mapToDouble(MonthlyDividendResponse::totalDividend) + .sum()) + .isEqualTo(expected), + () -> assertThat(actual).hasSize(12) + ); + } + + @Test + void 연간_배당금_조회시_배당금이_존재하지_않는_경우_정상적으로_조회된다() { + // given + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Portfolio portfolio = portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(tsla.getId(), 2), new PortfolioStock(aapl.getId(), 1)) + ) + ); + double expected = 0.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/yearly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends()).isEmpty() + ); + } + + @Test + void 연간_배당금_조회시_배당금이_존재하는_경우_정상적으로_조회된다() { + // given + Portfolio portfolio = stockAndDividendAndPortfolioGiven(); + double expected = 13.0; + + // when + YearlyDividendResponse actual = RestAssured + .given() + .log().all() + .contentType(ContentType.JSON) + .request() + .body(request()) + .when().get(String.format("api/portfolios/%s/yearly", portfolio.getId())) + .then().log().all() + .statusCode(SC_OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertAll( + () -> assertThat(actual.totalDividend()).isEqualTo(expected), + () -> assertThat(actual.dividends().size()).isEqualTo(2) + ); + } + + private PortfolioRequest request() { + return new PortfolioRequest(List.of( + new TickerShare(AAPL, 2), + new TickerShare(TSLA, 2) + )); + } + + private Portfolio stockAndDividendAndPortfolioGiven() { + Stock aapl = stockRepository.save(StockFixture.createStock(AAPL, Sector.TECHNOLOGY)); + Stock tsla = stockRepository.save(StockFixture.createStock(TSLA, Sector.CONSUMER_CYCLICAL)); + + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 1))); + dividendRepository.save(DividendFixture.createDividend( + aapl.getId(), + 2.5, + parseDate(InstantProvider.getLastYear(), 6))); + dividendRepository.save(DividendFixture.createDividend( + tsla.getId(), + 3.0, + parseDate(InstantProvider.getLastYear(), 6))); + + return portfolioRepository.save(PortfolioFixture.createPortfolio( + LocalDate.now().plusMonths(1).atStartOfDay().toInstant(ZoneOffset.UTC), + List.of(new PortfolioStock(aapl.getId(), 2), new PortfolioStock(tsla.getId(), 1)) + ) + ); + } + + private Instant parseDate(int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.of("UTC")); + return zonedDateTime.toInstant(); + } +} diff --git a/core/src/main/java/nexters/payout/core/time/InstantProvider.java b/core/src/main/java/nexters/payout/core/time/InstantProvider.java index cc775f1f..7886f5c2 100644 --- a/core/src/main/java/nexters/payout/core/time/InstantProvider.java +++ b/core/src/main/java/nexters/payout/core/time/InstantProvider.java @@ -42,6 +42,10 @@ public static Instant getYesterday() { return getNow().minusDays(1).atStartOfDay(ZoneId.of("UTC")).toInstant(); } + public static Instant getExpireAt() { + return getNow().plusMonths(1).atStartOfDay(ZoneId.of("UTC")).toInstant(); + } + public static Integer getYear(Instant date) { return ZonedDateTime.ofInstant(date, UTC).getYear(); } diff --git a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java index dbd020d6..fd1e12c9 100644 --- a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryCustom.java @@ -12,6 +12,8 @@ public interface DividendRepositoryCustom { Optional findByStockIdAndExDividendDate(UUID stockId, Instant date); List findAllByTickerAndYearAndMonth(String ticker, Integer year, Integer month); + List findAllByIdAndYearAndMonth(UUID id, Integer year, Integer month); List findAllByTickerAndYear(String ticker, Integer year); + List findAllByIdAndYear(UUID id, Integer year); void deleteByYearAndCreatedAt(Integer year, Instant createdAt); } diff --git a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java index cbf7b9e7..b0c07574 100644 --- a/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java +++ b/domain/src/main/java/nexters/payout/domain/dividend/infra/DividendRepositoryImpl.java @@ -51,6 +51,18 @@ public List findAllByTickerAndYearAndMonth(String ticker, Integer year .fetch(); } + @Override + public List findAllByIdAndYearAndMonth(UUID id, Integer year, Integer month) { + + return queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(dividend1.exDividendDate.year().eq(year) + .and(dividend1.exDividendDate.month().eq(month)) + .and(stock.id.eq(id))) + .fetch(); + } + @Override public List findAllByTickerAndYear(String ticker, Integer year) { @@ -62,6 +74,17 @@ public List findAllByTickerAndYear(String ticker, Integer year) { .fetch(); } + @Override + public List findAllByIdAndYear(UUID id, Integer year) { + + return queryFactory + .selectFrom(dividend1) + .innerJoin(stock).on(dividend1.stockId.eq(stock.id)) + .where(dividend1.exDividendDate.year().eq(year) + .and(stock.id.eq(id))) + .fetch(); + } + @Override public void deleteByYearAndCreatedAt(Integer year, Instant createdAt) { @@ -73,4 +96,6 @@ public void deleteByYearAndCreatedAt(Integer year, Instant createdAt) { .and(dividend1.createdAt.dayOfMonth().eq(InstantProvider.getDayOfMonth(createdAt)))) .execute(); } + + } diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java index 1bedc802..da8501f7 100644 --- a/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/Portfolio.java @@ -1,17 +1,15 @@ package nexters.payout.domain.portfolio.domain; import jakarta.persistence.*; -import lombok.Getter; import nexters.payout.domain.BaseEntity; import java.time.Instant; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.UUID; @Entity -@Getter public class Portfolio extends BaseEntity { @Embedded @@ -23,12 +21,22 @@ public Portfolio() { super(null); } + public Portfolio(final UUID id, final Instant expireAt, List stocks) { + super(id); + this.portfolioStocks = new PortfolioStocks(stocks); + this.expireAt = expireAt; + } + public Portfolio(final Instant expireAt, List stocks) { super(null); this.portfolioStocks = new PortfolioStocks(stocks); this.expireAt = expireAt; } + public List portfolioStocks() { + return Collections.unmodifiableList(portfolioStocks.stockShares()); + } + public boolean isExpired() { return expireAt.isAfter(Instant.now()); } diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java index 799ef585..bfe3e0bf 100644 --- a/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/PortfolioStocks.java @@ -25,7 +25,7 @@ public PortfolioStocks(List stocks) { portfolioStocks = stocks; } - public List getPortfolioStocks() { + public List stockShares() { return Collections.unmodifiableList(portfolioStocks); } } diff --git a/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java b/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java new file mode 100644 index 00000000..51442250 --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/portfolio/domain/exception/PortfolioNotFoundException.java @@ -0,0 +1,12 @@ +package nexters.payout.domain.portfolio.domain.exception; + +import nexters.payout.core.exception.error.NotFoundException; + +import java.util.UUID; + +public class PortfolioNotFoundException extends NotFoundException { + + public PortfolioNotFoundException(UUID id) { + super(String.format("not found portfolio [%s]", id)); + } +} diff --git a/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java b/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java new file mode 100644 index 00000000..54ed6dad --- /dev/null +++ b/domain/src/main/java/nexters/payout/domain/stock/domain/exception/StockIdNotFoundException.java @@ -0,0 +1,12 @@ +package nexters.payout.domain.stock.domain.exception; + +import nexters.payout.core.exception.error.NotFoundException; + +import java.util.UUID; + +public class StockIdNotFoundException extends NotFoundException { + + public StockIdNotFoundException(UUID id) { + super(String.format("not found stock id [%s]", id)); + } +} diff --git a/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java b/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java index 59ffb6e4..542b8573 100644 --- a/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java +++ b/domain/src/testFixtures/java/nexters/payout/domain/PortfolioFixture.java @@ -12,7 +12,15 @@ public class PortfolioFixture { public static UUID STOCK_ID = UUID.randomUUID(); + public static Portfolio createPortfolio(UUID id, Instant expireAt, List stocks) { + return new Portfolio(id, expireAt, stocks); + } + public static Portfolio createPortfolio(Instant expireAt, List stocks) { - return new Portfolio(expireAt, stocks); + return new Portfolio(UUID.randomUUID(), expireAt, stocks); + } + + public static Portfolio createPortfolio(List stocks) { + return new Portfolio(UUID.randomUUID(), Instant.now(), stocks); } }