Skip to content

Commit

Permalink
[feat] 포트폴리오 상세 조회 및 종목 조회 SSE 적용 (#24)
Browse files Browse the repository at this point in the history
* #17 fix: 실시간 현재 주식가를 redis에 저장하는 Duration 제거

- 5초에 한번씩 계속 갱신하기 때문에 1분동안 저장하는 것은 모순된다고 판단하여 제거함

* #17 fix: 당일변동 금액 공식 수정

* #17 feat: 직전거래일 종가 조회 구현

* #17 feat: 직전거래일 가격을 관리하는 매니저 객체 구현

- 종목에 따른 직전거래일 종가를 관리하는 객체입니다.
- redis에 저장된 종가를 참조합니다.

* #17 fix: 당일 변동 금액 공식 변경

- 종가 - 시가 -> 시가 -> 종가로 변경

* #17 fix: 테스트 코드 수정

- 당일 변동 금액 수정에 따른 테스트 코드 수정

* #17 fix: 직전거래일 종가 조회 메소드의 리턴 타입 변경

- 티커심볼과 종가가 같이 저장될 수 있도록 Response 객체로 변경

* #17 feat: LastDayClosingPriceManager hasPrice 메소드 구현

- 해당 메소드는 티커 심볼 키값에 따른 종가값이 있는지 검사하는 메소드이다

* #17 feat: LastDayClosingPriceResponse 클래스 정의

- 해당 객체는 한국투자증권 api 서버에 직전거래일 종가를 조회에 따른 응답값을 저장하는 객체입니다.

* #17 feat: 직전거래일 종가를 갱신하는 메소드 구현

- 해당 메소드는 종목의 직전거래일 종가가 없다면 한국투자증권 api 서버에 요청하여 redis 저장소에 저장하는 기능입니다

* #17 fix: PortfolioHolding 객체에 직전거래일 종가를 매개변수로 전달하도록 수정

* #17 fix: 종가 갱신을 refreshCurrentPrice 메소드로 이동

* #17 fix: 주식 전일 종가를 api 서버로부터 가져오도록 수정

* #17 docs: git submodules 추가

* docs: scret 디렉토리 삭제

* docs: application-secret.yml 파일 추가

* docs: .gitmodules 삭제

* docs: application-secret.yml 추가

* .gitmodules 제거

* docs: application-secret.yml 추가

* #17 docs: cicd 워크플로우 설정에 submodules 설정  추가

* #17 style: application-secret 변경 테스트

* #17 docs: gitmodules branch 설정

* #19 docs: analyze profile 추가

* #19 fix: 시크릿 정보 업데이트

* #16 docs: 시크릿 정보 업데이트

* #16 feat: 포트폴리오 상세 조회 및 종목 조회 sse 방식으로 변경

* #16 style: import 정리

* #16 feat: 포트폴리오를 찾지 못한 경우 예외 처리 추가

* #16 test: 포트폴리오 상세 조회 sse 예외 테스트 코드 추가

- 포트폴리오 번호가 존재하지 않는 경우

* #16 fix: emitter onCompletion 삭제

- sse 이벤트 완료시 쓰레드풀을 종료하지 않도록 합니다.
  • Loading branch information
yonghwankim-dev authored Nov 24, 2023
1 parent c5af417 commit 8b2d13a
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 21 deletions.
1 change: 1 addition & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v3
with:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package codesquad.fineants.spring.api.portfolio_stock;

import java.util.List;
import java.util.stream.Collectors;
import java.io.IOException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import javax.validation.Valid;

Expand All @@ -14,30 +16,27 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import codesquad.fineants.domain.oauth.support.AuthMember;
import codesquad.fineants.domain.oauth.support.AuthPrincipalMember;
import codesquad.fineants.domain.portfolio_holding.PortfolioHolding;
import codesquad.fineants.domain.stock.Stock;
import codesquad.fineants.spring.api.kis.KisService;
import codesquad.fineants.spring.api.kis.manager.CurrentPriceManager;
import codesquad.fineants.spring.api.errors.exception.FineAntsException;
import codesquad.fineants.spring.api.kis.manager.LastDayClosingPriceManager;
import codesquad.fineants.spring.api.portfolio.PortFolioService;
import codesquad.fineants.spring.api.portfolio_stock.request.PortfolioStockCreateRequest;
import codesquad.fineants.spring.api.portfolio_stock.response.PortfolioHoldingsResponse;
import codesquad.fineants.spring.api.response.ApiResponse;
import codesquad.fineants.spring.api.success.code.PortfolioStockSuccessCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequestMapping("/api/portfolio/{portfolioId}/holdings")
@RequiredArgsConstructor
@RestController
public class PortfolioStockRestController {

private static final ScheduledExecutorService sseExecutor = Executors.newScheduledThreadPool(100);

private final PortfolioStockService portfolioStockService;
private final KisService kisService;
private final PortFolioService portFolioService;
private final CurrentPriceManager currentPriceManager;
private final LastDayClosingPriceManager lastDayClosingPriceManager;

@ResponseStatus(HttpStatus.CREATED)
Expand All @@ -58,16 +57,23 @@ public ApiResponse<Void> deletePortfolioStock(@PathVariable Long portfolioId,
}

@GetMapping
public ApiResponse<PortfolioHoldingsResponse> readMyPortfolioStocks(@PathVariable Long portfolioId) {
List<String> tickerSymbols = portFolioService.findPortfolio(portfolioId).getPortfolioHoldings().stream()
.map(PortfolioHolding::getStock)
.map(Stock::getTickerSymbol)
.filter(tickerSymbol -> !currentPriceManager.hasCurrentPrice(tickerSymbol))
.collect(Collectors.toList());
kisService.refreshCurrentPrice(tickerSymbols);
public SseEmitter readMyPortfolioStocks(@PathVariable Long portfolioId) {
SseEmitter emitter = new SseEmitter();
sseExecutor.scheduleAtFixedRate(generateSseEventTask(portfolioId, emitter), 0, 5L, TimeUnit.SECONDS);
return emitter;
}

PortfolioHoldingsResponse response = portfolioStockService.readMyPortfolioStocks(portfolioId,
lastDayClosingPriceManager);
return ApiResponse.success(PortfolioStockSuccessCode.OK_READ_PORTFOLIO_STOCKS, response);
private Runnable generateSseEventTask(Long portfolioId, SseEmitter emitter) {
return () -> {
try {
SseEmitter.SseEventBuilder event = SseEmitter.event()
.data(portfolioStockService.readMyPortfolioStocks(portfolioId, lastDayClosingPriceManager))
.name("sse event - myPortfolioStocks");
emitter.send(event);
} catch (IOException | FineAntsException e) {
log.error(e.getMessage(), e);
emitter.completeWithError(e);
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package codesquad.fineants.spring.api.portfolio_stock;

import static org.assertj.core.api.Assertions.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import com.fasterxml.jackson.databind.ObjectMapper;

import codesquad.fineants.domain.member.Member;
import codesquad.fineants.domain.oauth.support.AuthMember;
import codesquad.fineants.domain.oauth.support.AuthPrincipalArgumentResolver;
import codesquad.fineants.domain.portfolio.Portfolio;
import codesquad.fineants.domain.portfolio_gain_history.PortfolioGainHistory;
import codesquad.fineants.domain.portfolio_holding.PortfolioHolding;
import codesquad.fineants.domain.purchase_history.PurchaseHistory;
import codesquad.fineants.domain.stock.Market;
import codesquad.fineants.domain.stock.Stock;
import codesquad.fineants.spring.api.errors.errorcode.PortfolioErrorCode;
import codesquad.fineants.spring.api.errors.exception.NotFoundResourceException;
import codesquad.fineants.spring.api.errors.handler.GlobalExceptionHandler;
import codesquad.fineants.spring.api.kis.manager.LastDayClosingPriceManager;
import codesquad.fineants.spring.api.portfolio_stock.response.PortfolioHoldingsResponse;
import codesquad.fineants.spring.config.JpaAuditingConfiguration;

@ActiveProfiles("test")
@WebMvcTest(controllers = PortfolioStockRestController.class)
@MockBean(JpaAuditingConfiguration.class)
class PortfolioStockRestControllerTest {
private MockMvc mockMvc;

@Autowired
private PortfolioStockRestController portfolioStockRestController;

@Autowired
private GlobalExceptionHandler globalExceptionHandler;

@Autowired
private MappingJackson2HttpMessageConverter converter;

@Autowired
private ObjectMapper objectMapper;

@MockBean
private AuthPrincipalArgumentResolver authPrincipalArgumentResolver;

@MockBean
private PortfolioStockService portfolioStockService;

@MockBean
private LastDayClosingPriceManager lastDayClosingPriceManager;

private Member member;

@BeforeEach
void setup() {
mockMvc = MockMvcBuilders.standaloneSetup(portfolioStockRestController)
.setControllerAdvice(globalExceptionHandler)
.setCustomArgumentResolvers(authPrincipalArgumentResolver)
.setMessageConverters(converter)
.defaultResponseCharacterEncoding(StandardCharsets.UTF_8)
.alwaysDo(print())
.build();
given(authPrincipalArgumentResolver.supportsParameter(any())).willReturn(true);

member = Member.builder()
.id(1L)
.nickname("일개미1234")
.email("kim1234@gmail.com")
.provider("local")
.password("kim1234@")
.profileUrl("profileValue")
.build();

AuthMember authMember = AuthMember.from(member);

given(authPrincipalArgumentResolver.resolveArgument(any(), any(), any(), any())).willReturn(authMember);
}

@DisplayName("사용자의 포트폴리오 상세 정보를 가져온다")
@Test
void readMyPortfolioStocks() throws Exception {
// given
long portfolioId = 1;
Portfolio portfolio = Portfolio.builder()
.id(1L)
.name("내꿈은 워렌버핏")
.securitiesFirm("토스")
.budget(1000000L)
.targetGain(1500000L)
.maximumLoss(900000L)
.member(member)
.build();
Stock stock = Stock.builder()
.tickerSymbol("005930")
.companyName("삼성전자보통주")
.companyNameEng("SamsungElectronics")
.stockCode("KR7005930003")
.market(Market.KOSPI)
.build();
long currentPrice = 60000;
PortfolioHolding portfolioHolding = PortfolioHolding.of(portfolio, stock, currentPrice);
portfolioHolding.addPurchaseHistory(PurchaseHistory.builder()
.purchaseDate(LocalDateTime.of(2023, 11, 1, 9, 30, 0))
.numShares(3L)
.purchasePricePerShare(50000.0)
.memo("첫구매")
.portFolioHolding(portfolioHolding)
.build());
portfolio.addPortfolioStock(portfolioHolding);

PortfolioGainHistory history = PortfolioGainHistory.empty();
Map<String, Long> lastDayClosingPriceMap = Map.of("005930", 50000L);
PortfolioHoldingsResponse mockResponse = PortfolioHoldingsResponse.of(portfolio, history,
List.of(portfolioHolding),
lastDayClosingPriceMap);
given(portfolioStockService.readMyPortfolioStocks(anyLong(), any(LastDayClosingPriceManager.class)))
.willReturn(mockResponse);

// when & then
String body = mockMvc.perform(get("/api/portfolio/{portfolioId}/holdings", portfolioId))
.andExpect(request().asyncStarted())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(body).contains(objectMapper.writeValueAsString(mockResponse));
}

@DisplayName("존재하지 않는 포트폴리오 번호를 가지고 포트폴리오 상세 정보를 가져올 수 없다")
@Test
void readMyPortfolioStocksWithNotFoundPortfolio() throws Exception {
// given
long portfolioId = 9999L;
given(portfolioStockService.readMyPortfolioStocks(anyLong(), any(LastDayClosingPriceManager.class)))
.willThrow(new NotFoundResourceException(PortfolioErrorCode.NOT_FOUND_PORTFOLIO));

// when
MvcResult result = mockMvc.perform(get("/api/portfolio/{portfolioId}/holdings", portfolioId))
.andExpect(request().asyncStarted())
.andReturn();

// then
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isNotFound())
.andExpect(jsonPath("message").value(equalTo("포트폴리오를 찾을 수 없습니다")));
}
}

0 comments on commit 8b2d13a

Please sign in to comment.