From db829cbd6546ecc58b47ca7a3cda67b73fb8d45e Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 6 Nov 2023 21:56:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20social=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 * feat: 토큰 정보 조회 기능 및 테스트 추가 * feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경 * fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성 * fix: 코드 리뷰 반영 --- .../application/AuthenticationService.java | 35 +++-- ...uth2AuthorizationServerRequestService.java | 21 ++- .../dto/AuthorizationTokenInfoResponse.java | 11 ++ .../api/presentation/MemberController.java | 4 +- .../global/common/util/GlobalConstant.java | 1 + .../global/common/util/TokenConstant.java | 13 ++ .../com/moabam/global/config/OAuthConfig.java | 3 +- .../handler/RestTemplateResponseHandler.java | 44 ++++++ .../AuthenticationServiceTest.java | 27 +++- ...AuthorizationServerRequestServiceTest.java | 131 ++++++++++++------ .../presentation/MemberControllerTest.java | 105 +++++++++++++- ...java => AuthorizationResponseFixture.java} | 12 +- src/test/resources/application.yml | 2 + 13 files changed, 337 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java create mode 100644 src/main/java/com/moabam/global/common/util/TokenConstant.java create mode 100644 src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java rename src/test/java/com/moabam/fixture/{AuthorizationTokenResponseFixture.java => AuthorizationResponseFixture.java} (57%) diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 11397a03..07d06d0d 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -10,10 +10,12 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.util.TokenConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -28,11 +30,33 @@ public class AuthenticationService { private final OAuthConfig oAuthConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; + public void redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizationCodeUri(); + oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); + } + + public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) { + validAuthorizationGrant(authorizationCodeResponse.code()); + return issueTokenToAuthorizationServer(authorizationCodeResponse.code()); + } + + public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { + String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); + ResponseEntity authorizationTokenInfoResponse + = oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + + return authorizationTokenInfoResponse.getBody(); + } + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } + private String generateTokenValue(String token) { + return TokenConstant.TOKEN_TYPE + GlobalConstant.SPACE + token; + } + private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) { UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder .fromUriString(oAuthConfig.provider().authorizationUri()) @@ -78,15 +102,4 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq return contents; } - - public void redirectToLoginPage(HttpServletResponse httpServletResponse) { - String authorizationCodeUri = getAuthorizationCodeUri(); - oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); - } - - public void requestToken(AuthorizationCodeResponse authorizationCodeResponse) { - validAuthorizationGrant(authorizationCodeResponse.code()); - issueTokenToAuthorizationServer(authorizationCodeResponse.code()); - // TODO 발급한 토큰으로 사용자의 정보 얻어와야함 : 프로필 & 닉네임 - } } diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index 339f1503..61cfa4e2 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -2,6 +2,7 @@ import java.io.IOException; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -11,9 +12,12 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.util.TokenConstant; import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.handler.RestTemplateResponseHandler; import com.moabam.global.error.model.ErrorMessage; import jakarta.servlet.http.HttpServletResponse; @@ -24,7 +28,9 @@ public class OAuth2AuthorizationServerRequestService { private final RestTemplate restTemplate; public OAuth2AuthorizationServerRequestService() { - restTemplate = new RestTemplate(); + restTemplate = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()) + .build(); } public void loginRequest(HttpServletResponse httpServletResponse, String authorizationCodeUri) { @@ -43,13 +49,14 @@ public ResponseEntity requestAuthorizationServer(Str MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8); HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); - ResponseEntity authorizationTokenResponse = restTemplate.exchange(tokenUri, - HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + return restTemplate.exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + } - if (authorizationTokenResponse.getStatusCode().isError()) { - throw new BadRequestException(ErrorMessage.REQUEST_FAILED); - } + public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) { + HttpHeaders headers = new HttpHeaders(); + headers.add(TokenConstant.AUTHORIZATION, tokenValue); + HttpEntity httpEntity = new HttpEntity<>(headers); - return authorizationTokenResponse; + return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); } } diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java b/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java new file mode 100644 index 00000000..9268516d --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthorizationTokenInfoResponse( + @JsonProperty("id") long id, + @JsonProperty("expires_in") String expiresIn, + @JsonProperty("app_id") String appId +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index b2c60743..0554173e 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -7,6 +7,7 @@ import com.moabam.api.application.AuthenticationService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public void socialLogin(HttpServletResponse httpServletResponse) { @GetMapping("/login/kakao/oauth") public void authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse) { - authenticationService.requestToken(authorizationCodeResponse); + AuthorizationTokenResponse tokenResponse = authenticationService.requestToken(authorizationCodeResponse); + authenticationService.requestTokenInfo(tokenResponse); } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index a74b791d..3146450a 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -10,6 +10,7 @@ public class GlobalConstant { public static final String COMMA = ","; public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; + public static final String SPACE = " "; public static final String TO = "_TO_"; public static final long EXPIRE_KNOCK = 12; diff --git a/src/main/java/com/moabam/global/common/util/TokenConstant.java b/src/main/java/com/moabam/global/common/util/TokenConstant.java new file mode 100644 index 00000000..0da12b30 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/TokenConstant.java @@ -0,0 +1,13 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TokenConstant { + + public static final String TOKEN_TYPE = "Bearer"; + public static final String ACCESS_TOKEN = "access_token"; + public static final String REFRESH_TOKEN = "refresh_token"; + public static final String AUTHORIZATION = "Authorization"; +} diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java index c83b6a77..9c2b545a 100644 --- a/src/main/java/com/moabam/global/config/OAuthConfig.java +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -23,7 +23,8 @@ public record Client( public record Provider( String authorizationUri, String redirectUri, - String tokenUri + String tokenUri, + String tokenInfo ) { } diff --git a/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java new file mode 100644 index 00000000..c234dbf1 --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java @@ -0,0 +1,44 @@ +package com.moabam.global.error.handler; + +import java.io.IOException; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResponseErrorHandler; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +@Component +public class RestTemplateResponseHandler implements ResponseErrorHandler { + + @Override + public boolean hasError(ClientHttpResponse response) { + try { + return response.getStatusCode().isError(); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + @Override + public void handleError(ClientHttpResponse response) { + try { + HttpStatusCode statusCode = response.getStatusCode(); + validResponse(statusCode); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + private void validResponse(HttpStatusCode statusCode) { + if (statusCode.is5xxServerError()) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + + if (statusCode.is4xxClientError()) { + throw new BadRequestException(ErrorMessage.INVALID_REQUEST_FIELD); + } + } +} diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index 744b466f..cd884e1a 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -22,10 +22,11 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.fixture.AuthorizationTokenResponseFixture; +import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -46,14 +47,15 @@ class AuthenticationServiceTest { @BeforeEach public void initParams() { oauthConfig = new OAuthConfig( - new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url"), + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", + "http://tokenInfo/url"), new OAuthConfig.Client("provider", "testtestetsttest", "testtesttest", "authorization_code", List.of("profile_nickname", "profile_image")) ); ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); noOAuthConfig = new OAuthConfig( - new OAuthConfig.Provider(null, null, null), + new OAuthConfig.Provider(null, null, null, null), new OAuthConfig.Client(null, null, null, null, null) ); noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService); @@ -115,7 +117,7 @@ void authorization_grant_success() { AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse("test", null, null, null); AuthorizationTokenResponse authorizationTokenResponse = - AuthorizationTokenResponseFixture.authorizationTokenResponse(); + AuthorizationResponseFixture.authorizationTokenResponse(); // When when(oAuth2AuthorizationServerRequestService.requestAuthorizationServer(anyString(), any())).thenReturn( @@ -157,4 +159,21 @@ void token_request_mapping_success() { () -> assertThat(authorizationTokenRequest.code()).isEqualTo(code) ); } + + @DisplayName("토큰 변경 성공") + @Test + void generate_token() { + // Given + AuthorizationTokenResponse tokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); + AuthorizationTokenInfoResponse tokenInfoResponse + = AuthorizationResponseFixture.authorizationTokenInfoResponse(); + + // When + when(oAuth2AuthorizationServerRequestService.tokenInfoRequest(eq(oauthConfig.provider().tokenInfo()), + eq("Bearer " + tokenResponse.accessToken()))) + .thenReturn(new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); + + // Then + assertThatNoException().isThrownBy(() -> authenticationService.requestTokenInfo(tokenResponse)); + } } diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java index 305aa885..c54b93f5 100644 --- a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -1,7 +1,7 @@ package com.moabam.api.application; import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; import java.io.IOException; @@ -14,10 +14,11 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -25,8 +26,10 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.error.exception.BadRequestException; @@ -43,11 +46,6 @@ public class OAuth2AuthorizationServerRequestServiceTest { @Mock RestTemplate restTemplate; - String uri = "https://authorization/url?" - + "response_type=code&" - + "client_id=testtestetsttest&" - + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; - @BeforeEach void initField() { ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); @@ -57,16 +55,21 @@ void initField() { @Nested class LoginPage { + String uri = "https://authorization/url?" + + "response_type=code&" + + "client_id=testtestetsttest&" + + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; + @DisplayName("로그인 페이지 접근 요청 성공") @Test - void authorization_code_uri_generate_success() throws IOException { - // given + void authorization_code_uri_generate_success() { + // Given MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); - // when + // When oAuth2AuthorizationServerRequestService.loginRequest(mockHttpServletResponse, uri); - // then + // Then assertThat(mockHttpServletResponse.getContentType()) .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); @@ -74,9 +77,9 @@ void authorization_code_uri_generate_success() throws IOException { @DisplayName("redirect 실패 테스트") @Test - void redirect_fail_test() { - // given - HttpServletResponse mockHttpServletResponse = Mockito.mock(HttpServletResponse.class); + void redirect_fail() { + // Given + HttpServletResponse mockHttpServletResponse = mock(HttpServletResponse.class); try { doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); @@ -92,32 +95,33 @@ void redirect_fail_test() { } } - @DisplayName("Authorization Server에 토큰 발급 요청") + @DisplayName("Authorization Server 토큰 발급 요청") @Nested class TokenRequest { @DisplayName("토큰 발급 요청 성공") @Test - void toekn_issue_request_success() { - // given + void token_issue_request_success() { + // Given String tokenUri = "test"; MultiValueMap uriParams = new LinkedMultiValueMap<>(); - ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); - // when - when(restTemplate.exchange( - eq(tokenUri), - eq(HttpMethod.POST), - any(HttpEntity.class), - eq(AuthorizationTokenResponse.class)) - ).thenReturn(authorizationTokenResponse); + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); // When - when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)); + oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams); // Then - assertThatNoException().isThrownBy( - () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)); + verify(restTemplate, times(1)) + .exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); } @DisplayName("토큰 발급 요청 실패") @@ -128,23 +132,70 @@ void token_issue_request_fail(int code) { String tokenUri = "test"; MultiValueMap uriParams = new LinkedMultiValueMap<>(); - ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); + // When + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)); + + // Then + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)) + .isInstanceOf(HttpClientErrorException.class); + } + } + + @DisplayName("토큰 정보 조회 발급 요청") + @Nested + class TokenInfoRequest { + + @DisplayName("토큰 정보 조회 요청 성공") + @Test + void token_info_request_success() { + // Given + String tokenInfoUri = "http://tokenInfo/uri"; + String tokenValue = "Bearer access-token"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", tokenValue); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // When + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(tokenInfoUri), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(AuthorizationTokenInfoResponse.class)); + oAuth2AuthorizationServerRequestService.tokenInfoRequest(tokenInfoUri, tokenValue); - when(restTemplate.exchange( - eq(tokenUri), - eq(HttpMethod.POST), - any(HttpEntity.class), - eq(AuthorizationTokenResponse.class)) - ).thenReturn(authorizationTokenResponse); + // Then + verify(restTemplate, times(1)) + .exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); + } + + @DisplayName("") + @ParameterizedTest + @ValueSource(ints = {400, 401}) + void token_issue_request_fail(int code) { + // Given + String tokenInfoUri = "http://tokenInfo/uri"; + String tokenValue = "Bearer access-token"; // When - when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(code)); + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(tokenInfoUri), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(AuthorizationTokenInfoResponse.class)); // Then - assertThatThrownBy( - () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.REQUEST_FAILED.getMessage()); + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.tokenInfoRequest(tokenInfoUri, tokenValue)) + .isInstanceOf(HttpClientErrorException.class); } } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 114d5ba8..faae1803 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,19 +1,26 @@ package com.moabam.api.presentation; import static com.moabam.global.common.util.OAuthParameterNames.*; +import static org.mockito.BDDMockito.*; import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.*; 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.*; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; @@ -27,11 +34,14 @@ import org.springframework.web.util.UriComponentsBuilder; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.AuthenticationService; import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; -import com.moabam.fixture.AuthorizationTokenResponseFixture; +import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.handler.RestTemplateResponseHandler; @SpringBootTest @AutoConfigureMockMvc @@ -46,21 +56,26 @@ class MemberControllerTest { @Autowired OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + @SpyBean + AuthenticationService authenticationService; + @Autowired OAuthConfig oAuthConfig; - static RestTemplate restTemplate; + static RestTemplateBuilder restTemplateBuilder; MockRestServiceServer mockRestServiceServer; @BeforeAll static void allSetUp() { - restTemplate = new RestTemplate(); + restTemplateBuilder = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()); } @BeforeEach void setUp() { // TODO 추후 RestTemplate -> REstTemplateBuilder & Bean등록하여 테스트 코드도 일부 변경됨 + RestTemplate restTemplate = restTemplateBuilder.build(); ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); } @@ -81,7 +96,7 @@ void authorization_code_request_success() throws Exception { ResultActions result = mockMvc.perform(get("/members")); result.andExpect(status().is3xxRedirection()) - .andExpect(header().string("Content-type", + .andExpect(MockMvcResultMatchers.header().string("Content-type", MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); } @@ -97,19 +112,95 @@ void authorization_token_request_success() throws Exception { contentParams.add(CODE, "test"); contentParams.add(CLIENT_SECRET, oAuthConfig.client().clientSecret()); - String response = objectMapper.writeValueAsString( - AuthorizationTokenResponseFixture.authorizationTokenResponse()); - AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse("test", null, null, null); + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + AuthorizationTokenResponse authorizationTokenResponse = + AuthorizationResponseFixture.authorizationTokenResponse(); + + String response = objectMapper.writeValueAsString(authorizationTokenResponse); + + // When + doReturn(AuthorizationResponseFixture.authorizationTokenInfoResponse()) + .when(authenticationService).requestTokenInfo(authorizationTokenResponse); + // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) .andExpect(MockRestRequestMatchers.content().formData(contentParams)) .andExpect(MockRestRequestMatchers.content().contentType("application/x-www-form-urlencoded;charset=UTF-8")) .andExpect(method(HttpMethod.POST)) .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("Authorization Token 발급 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401, 403, 429, 500, 502, 503}) + void authorization_token_request_fail(int code) throws Exception { + // given + MultiValueMap contentParams = new LinkedMultiValueMap<>(); + contentParams.add(GRANT_TYPE, oAuthConfig.client().authorizationGrantType()); + contentParams.add(CLIENT_ID, oAuthConfig.client().clientId()); + contentParams.add(REDIRECT_URI, oAuthConfig.provider().redirectUri()); + contentParams.add(CODE, "test"); + contentParams.add(CLIENT_SECRET, oAuthConfig.client().clientSecret()); + + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) + .andExpect(MockRestRequestMatchers.content().formData(contentParams)) + .andExpect(MockRestRequestMatchers.content().contentType("application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatusCode.valueOf(code))); + + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("토큰 정보 조회 요청") + @Test + void token_info_request_success() throws Exception { + // given + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + + // When + doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) + .when(authenticationService).requestToken(authorizationCodeResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withStatus(HttpStatusCode.valueOf(200))); + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isOk()); } + + @DisplayName("토큰 정보 요청 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401}) + void token_info_response_fail(int code) throws Exception { + // given + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + + // when + doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) + .when(authenticationService).requestToken(authorizationCodeResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withStatus(HttpStatusCode.valueOf(code))); + + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isBadRequest()); + } } diff --git a/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java b/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java similarity index 57% rename from src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java rename to src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java index ecfa1506..1dc1f3d6 100644 --- a/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java +++ b/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java @@ -1,8 +1,10 @@ package com.moabam.fixture; +import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -public class AuthorizationTokenResponseFixture { +public final class AuthorizationResponseFixture { static final String tokenType = "tokenType"; static final String accessToken = "accessToken"; @@ -12,6 +14,14 @@ public class AuthorizationTokenResponseFixture { static final String refreshTokenExpiresIn = "refs"; static final String scope = "scope"; + public static AuthorizationCodeResponse successCodeResponse() { + return new AuthorizationCodeResponse("test", null, null, null); + } + + public static AuthorizationTokenInfoResponse authorizationTokenInfoResponse() { + return new AuthorizationTokenInfoResponse(1L, "expiresIn", "appId"); + } + public static AuthorizationTokenResponse authorizationTokenResponse() { return new AuthorizationTokenResponse(tokenType, accessToken, idToken, expiresin, refreshToken, refreshTokenExpiresIn, scope); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index a116451d..00bf4697 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -28,3 +28,5 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test + token-info: https://api.token.com/test + From 0201932fdaf454758e400287c8b4f327128e483d Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 6 Nov 2023 21:57:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ResponseStatus + DTO 방식으로 변경 * feat: 아이템, 인벤토리 Entity 생성 * feat: 아이템 목록 조회 API 구현 * test: containsExactly 검증으로 수정 * test: 아이템 목록 조회 Service 테스트 * test: 인벤토리 아이템 목록 조회 Repository 테스트 * feat: Stream 유틸 클래스 생성 및 적용 * fix: ItemFixture를 통한 아이템 생성 시 build() 추가 * test: 구매하지 않은 아이템 목록 조회 Repository 테스트 * feat: MethodArgumentTypeMismatchException handler 추가 * test: 아이템 목록 조회 Controller 테스트 * fix: Mapper 생성자 접근 레벨 private으로 변경 * feat: ItemType 생성 및 적용 * refactor: 잘못된 요청 타입 에러 메시지 상수화 --- .../moabam/api/application/ItemService.java | 31 ++++++ .../moabam/api/domain/entity/Inventory.java | 52 ++++++++++ .../com/moabam/api/domain/entity/Item.java | 90 +++++++++++++++++ .../api/domain/entity/enums/ItemCategory.java | 6 ++ .../api/domain/entity/enums/ItemType.java | 7 ++ .../repository/InventoryRepository.java | 9 ++ .../repository/InventorySearchRepository.java | 33 +++++++ .../api/domain/repository/ItemRepository.java | 9 ++ .../repository/ItemSearchRepository.java | 43 +++++++++ .../java/com/moabam/api/dto/BugMapper.java | 2 +- .../java/com/moabam/api/dto/ItemMapper.java | 33 +++++++ .../java/com/moabam/api/dto/ItemResponse.java | 17 ++++ .../com/moabam/api/dto/ItemsResponse.java | 13 +++ .../com/moabam/api/dto/ProductMapper.java | 7 +- .../api/presentation/ItemController.java | 28 ++++++ .../api/presentation/ProductController.java | 8 +- .../global/common/util/StreamUtils.java | 17 ++++ .../error/handler/GlobalExceptionHandler.java | 18 +++- .../global/error/model/ErrorMessage.java | 2 + .../api/application/ItemServiceTest.java | 57 +++++++++++ .../api/application/ProductServiceTest.java | 7 +- .../InventorySearchRepositoryTest.java | 67 +++++++++++++ .../repository/ItemSearchRepositoryTest.java | 96 +++++++++++++++++++ .../api/presentation/ItemControllerTest.java | 64 +++++++++++++ .../presentation/ProductControllerTest.java | 2 +- .../com/moabam/fixture/InventoryFixture.java | 14 +++ .../java/com/moabam/fixture/ItemFixture.java | 40 ++++++++ 27 files changed, 757 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ItemService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Inventory.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Item.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ItemType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/InventoryRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ItemRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/ItemMapper.java create mode 100644 src/main/java/com/moabam/api/dto/ItemResponse.java create mode 100644 src/main/java/com/moabam/api/dto/ItemsResponse.java create mode 100644 src/main/java/com/moabam/api/presentation/ItemController.java create mode 100644 src/main/java/com/moabam/global/common/util/StreamUtils.java create mode 100644 src/test/java/com/moabam/api/application/ItemServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/api/presentation/ItemControllerTest.java create mode 100644 src/test/java/com/moabam/fixture/InventoryFixture.java create mode 100644 src/test/java/com/moabam/fixture/ItemFixture.java diff --git a/src/main/java/com/moabam/api/application/ItemService.java b/src/main/java/com/moabam/api/application/ItemService.java new file mode 100644 index 00000000..9d9aca77 --- /dev/null +++ b/src/main/java/com/moabam/api/application/ItemService.java @@ -0,0 +1,31 @@ +package com.moabam.api.application; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.dto.ItemMapper; +import com.moabam.api.dto.ItemsResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ItemService { + + private final ItemSearchRepository itemSearchRepository; + private final InventorySearchRepository inventorySearchRepository; + + public ItemsResponse getItems(Long memberId, ItemType type) { + List purchasedItems = inventorySearchRepository.findItems(memberId, type); + List notPurchasedItems = itemSearchRepository.findNotPurchasedItems(memberId, type); + + return ItemMapper.toItemsResponse(purchasedItems, notPurchasedItems); + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Inventory.java b/src/main/java/com/moabam/api/domain/entity/Inventory.java new file mode 100644 index 00000000..4921ab7d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Inventory.java @@ -0,0 +1,52 @@ +package com.moabam.api.domain.entity; + +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "inventory", indexes = @Index(name = "idx_member_id", columnList = "member_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Inventory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id", updatable = false, nullable = false) + private Item item; + + @Column(name = "is_default", nullable = false) + @ColumnDefault("false") + private boolean isDefault; + + @Builder + private Inventory(Long memberId, Item item, boolean isDefault) { + this.memberId = requireNonNull(memberId); + this.item = requireNonNull(item); + this.isDefault = isDefault; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Item.java b/src/main/java/com/moabam/api/domain/entity/Item.java new file mode 100644 index 00000000..defce5f4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Item.java @@ -0,0 +1,90 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.entity.enums.ItemCategory; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Item extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(value = EnumType.STRING) + @Column(name = "type", nullable = false) + private ItemType type; + + @Enumerated(value = EnumType.STRING) + @Column(name = "category", nullable = false) + private ItemCategory category; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "image", nullable = false) + private String image; + + @Column(name = "bug_price", nullable = false) + @ColumnDefault("0") + private int bugPrice; + + @Column(name = "golden_bug_price", nullable = false) + @ColumnDefault("0") + private int goldenBugPrice; + + @Column(name = "unlock_level", nullable = false) + @ColumnDefault("1") + private int unlockLevel; + + @Builder + private Item(ItemType type, ItemCategory category, String name, String image, int bugPrice, int goldenBugPrice, + Integer unlockLevel) { + this.type = requireNonNull(type); + this.category = requireNonNull(category); + this.name = requireNonNull(name); + this.image = requireNonNull(image); + this.bugPrice = validatePrice(bugPrice); + this.goldenBugPrice = validatePrice(goldenBugPrice); + this.unlockLevel = validateLevel(requireNonNullElse(unlockLevel, 1)); + } + + private int validatePrice(int price) { + if (price < 0) { + throw new BadRequestException(INVALID_PRICE); + } + + return price; + } + + private int validateLevel(int level) { + if (level < 1) { + throw new BadRequestException(INVALID_LEVEL); + } + + return level; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java new file mode 100644 index 00000000..581698c0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java @@ -0,0 +1,6 @@ +package com.moabam.api.domain.entity.enums; + +public enum ItemCategory { + + SKIN; +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java new file mode 100644 index 00000000..f21fbc68 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java @@ -0,0 +1,7 @@ +package com.moabam.api.domain.entity.enums; + +public enum ItemType { + + MORNING, + NIGHT; +} diff --git a/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java b/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java new file mode 100644 index 00000000..bac07502 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Inventory; + +public interface InventoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java new file mode 100644 index 00000000..e15c017c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java @@ -0,0 +1,33 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QInventory.*; +import static com.moabam.api.domain.entity.QItem.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class InventorySearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findItems(Long memberId, ItemType type) { + return jpaQueryFactory.selectFrom(inventory) + .join(inventory.item, item) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(type, inventory.item.type::eq)) + .orderBy(inventory.createdAt.desc()) + .select(item) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/ItemRepository.java b/src/main/java/com/moabam/api/domain/repository/ItemRepository.java new file mode 100644 index 00000000..ae5eede0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ItemRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Item; + +public interface ItemRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java new file mode 100644 index 00000000..8f799973 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QInventory.*; +import static com.moabam.api.domain.entity.QItem.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ItemSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findNotPurchasedItems(Long memberId, ItemType type) { + return jpaQueryFactory.selectFrom(item) + .leftJoin(inventory) + .on(inventory.item.id.eq(item.id)) + .where( + DynamicQuery.generateEq(type, item.type::eq), + DynamicQuery.generateEq(memberId, this::filterByMemberId)) + .orderBy( + item.unlockLevel.asc(), + item.bugPrice.asc(), + item.goldenBugPrice.asc(), + item.name.asc()) + .fetch(); + } + + private BooleanExpression filterByMemberId(Long memberId) { + return inventory.memberId.isNull() + .or(inventory.memberId.ne(memberId)); + } +} diff --git a/src/main/java/com/moabam/api/dto/BugMapper.java b/src/main/java/com/moabam/api/dto/BugMapper.java index 57bea850..d596c6dd 100644 --- a/src/main/java/com/moabam/api/dto/BugMapper.java +++ b/src/main/java/com/moabam/api/dto/BugMapper.java @@ -5,7 +5,7 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class BugMapper { public static BugResponse toBugResponse(Bug bug) { diff --git a/src/main/java/com/moabam/api/dto/ItemMapper.java b/src/main/java/com/moabam/api/dto/ItemMapper.java new file mode 100644 index 00000000..b468a5e4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemMapper.java @@ -0,0 +1,33 @@ +package com.moabam.api.dto; + +import java.util.List; + +import com.moabam.api.domain.entity.Item; +import com.moabam.global.common.util.StreamUtils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ItemMapper { + + public static ItemResponse toItemResponse(Item item) { + return ItemResponse.builder() + .id(item.getId()) + .type(item.getType().name()) + .category(item.getCategory().name()) + .name(item.getName()) + .image(item.getImage()) + .level(item.getUnlockLevel()) + .bugPrice(item.getBugPrice()) + .goldenBugPrice(item.getGoldenBugPrice()) + .build(); + } + + public static ItemsResponse toItemsResponse(List purchasedItems, List notPurchasedItems) { + return ItemsResponse.builder() + .purchasedItems(StreamUtils.map(purchasedItems, ItemMapper::toItemResponse)) + .notPurchasedItems(StreamUtils.map(notPurchasedItems, ItemMapper::toItemResponse)) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/dto/ItemResponse.java b/src/main/java/com/moabam/api/dto/ItemResponse.java new file mode 100644 index 00000000..13c83ace --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemResponse.java @@ -0,0 +1,17 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record ItemResponse( + Long id, + String type, + String category, + String name, + String image, + int level, + int bugPrice, + int goldenBugPrice +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ItemsResponse.java b/src/main/java/com/moabam/api/dto/ItemsResponse.java new file mode 100644 index 00000000..a0d323c8 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemsResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record ItemsResponse( + List purchasedItems, + List notPurchasedItems +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ProductMapper.java b/src/main/java/com/moabam/api/dto/ProductMapper.java index 0851d490..bd2fce60 100644 --- a/src/main/java/com/moabam/api/dto/ProductMapper.java +++ b/src/main/java/com/moabam/api/dto/ProductMapper.java @@ -3,11 +3,12 @@ import java.util.List; import com.moabam.api.domain.entity.Product; +import com.moabam.global.common.util.StreamUtils; import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ProductMapper { public static ProductResponse toProductResponse(Product product) { @@ -22,9 +23,7 @@ public static ProductResponse toProductResponse(Product product) { public static ProductsResponse toProductsResponse(List products) { return ProductsResponse.builder() - .products(products.stream() - .map(ProductMapper::toProductResponse) - .toList()) + .products(StreamUtils.map(products, ProductMapper::toProductResponse)) .build(); } } diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java new file mode 100644 index 00000000..47bd5880 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -0,0 +1,28 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.ItemService; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.dto.ItemsResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/items") +@RequiredArgsConstructor +public class ItemController { + + private final ItemService itemService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ItemsResponse getItems(@RequestParam ItemType type) { + return itemService.getItems(1L, type); + } +} diff --git a/src/main/java/com/moabam/api/presentation/ProductController.java b/src/main/java/com/moabam/api/presentation/ProductController.java index c2231d5b..7a686810 100644 --- a/src/main/java/com/moabam/api/presentation/ProductController.java +++ b/src/main/java/com/moabam/api/presentation/ProductController.java @@ -1,8 +1,9 @@ package com.moabam.api.presentation; -import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.ProductService; @@ -18,7 +19,8 @@ public class ProductController { private final ProductService productService; @GetMapping - public ResponseEntity getProducts() { - return ResponseEntity.ok(productService.getProducts()); + @ResponseStatus(HttpStatus.OK) + public ProductsResponse getProducts() { + return productService.getProducts(); } } diff --git a/src/main/java/com/moabam/global/common/util/StreamUtils.java b/src/main/java/com/moabam/global/common/util/StreamUtils.java new file mode 100644 index 00000000..2820d908 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/StreamUtils.java @@ -0,0 +1,17 @@ +package com.moabam.global.common.util; + +import java.util.List; +import java.util.function.Function; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class StreamUtils { + + public static List map(List list, Function mapper) { + return list.stream() + .map(mapper) + .toList(); + } +} diff --git a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 2dc1f65d..b641f69b 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -1,8 +1,11 @@ package com.moabam.global.error.handler; +import static com.moabam.global.error.model.ErrorMessage.*; + import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; @@ -10,6 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; @@ -18,7 +22,6 @@ import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.exception.UnauthorizedException; -import com.moabam.global.error.model.ErrorMessage; import com.moabam.global.error.model.ErrorResponse; @RestControllerAdvice @@ -76,6 +79,17 @@ protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotV validation.put(fieldError.getField(), fieldError.getDefaultMessage()); } - return new ErrorResponse(ErrorMessage.INVALID_REQUEST_FIELD.getMessage(), validation); + return new ErrorResponse(INVALID_REQUEST_FIELD.getMessage(), validation); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) { + String typeName = Optional.ofNullable(exception.getRequiredType()) + .map(Class::getSimpleName) + .orElse(""); + String message = String.format(INVALID_REQUEST_VALUE_TYPE_FORMAT.getMessage(), exception.getValue(), typeName); + + return new ErrorResponse(message, null); } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 4421ceea..3944c5b0 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -8,6 +8,7 @@ public enum ErrorMessage { INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + INVALID_REQUEST_VALUE_TYPE_FORMAT("'%s' 값은 유효한 %s 값이 아닙니다."), ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), @@ -26,6 +27,7 @@ public enum ErrorMessage { INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), INVALID_PRICE("가격은 0 이상이어야 합니다."), INVALID_QUANTITY("수량은 1 이상이어야 합니다."), + INVALID_LEVEL("레벨은 1 이상이어야 합니다."), FCM_INIT_FAILED("파이어베이스 설정을 실패했습니다."), FCM_TOKEN_NOT_FOUND("해당 유저는 접속 중이 아닙니다."), diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/ItemServiceTest.java new file mode 100644 index 00000000..cc2b0a43 --- /dev/null +++ b/src/test/java/com/moabam/api/application/ItemServiceTest.java @@ -0,0 +1,57 @@ +package com.moabam.api.application; + +import static com.moabam.fixture.ItemFixture.*; +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.dto.ItemResponse; +import com.moabam.api.dto.ItemsResponse; +import com.moabam.global.common.util.StreamUtils; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + + @InjectMocks + ItemService itemService; + + @Mock + ItemSearchRepository itemSearchRepository; + + @Mock + InventorySearchRepository inventorySearchRepository; + + @DisplayName("아이템 목록을 조회한다.") + @Test + void get_products_success() { + // given + Long memberId = 1L; + ItemType type = ItemType.MORNING; + Item item1 = morningSantaSkin().build(); + Item item2 = morningKillerSkin().build(); + given(inventorySearchRepository.findItems(memberId, type)).willReturn(List.of(item1, item2)); + given(itemSearchRepository.findNotPurchasedItems(memberId, type)).willReturn(emptyList()); + + // when + ItemsResponse response = itemService.getItems(memberId, type); + + // then + List purchasedItemNames = StreamUtils.map(response.purchasedItems(), ItemResponse::name); + assertThat(response.purchasedItems()).hasSize(2); + assertThat(purchasedItemNames).containsExactly(MORNING_SANTA_SKIN_NAME, MORNING_KILLER_SKIN_NAME); + assertThat(response.notPurchasedItems()).isEmpty(); + } +} diff --git a/src/test/java/com/moabam/api/application/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java index f3445bd3..bb63597f 100644 --- a/src/test/java/com/moabam/api/application/ProductServiceTest.java +++ b/src/test/java/com/moabam/api/application/ProductServiceTest.java @@ -17,6 +17,7 @@ import com.moabam.api.domain.repository.ProductRepository; import com.moabam.api.dto.ProductResponse; import com.moabam.api.dto.ProductsResponse; +import com.moabam.global.common.util.StreamUtils; @ExtendWith(MockitoExtension.class) class ProductServiceTest { @@ -39,10 +40,8 @@ void get_products_success() { ProductsResponse response = productService.getProducts(); // then - List productNames = response.products().stream() - .map(ProductResponse::name) - .toList(); + List productNames = StreamUtils.map(response.products(), ProductResponse::name); assertThat(response.products()).hasSize(2); - assertThat(productNames).containsOnly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); + assertThat(productNames).containsExactly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); } } diff --git a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java new file mode 100644 index 00000000..83463363 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java @@ -0,0 +1,67 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.fixture.InventoryFixture.*; +import static com.moabam.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; + +@SpringBootTest +@Transactional(readOnly = true) +class InventorySearchRepositoryTest { + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @Autowired + InventorySearchRepository inventorySearchRepository; + + @DisplayName("타입으로 인벤토리에 있는 아이템 목록을 구매일 순으로 조회한다.") + @Test + void find_items_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + inventoryRepository.save(inventory(memberId, morningKillerSkin)); + Item nightMageSkin = itemRepository.save(nightMageSkin()); + inventoryRepository.save(inventory(memberId, nightMageSkin)); + + // when + List actual = inventorySearchRepository.findItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningKillerSkin, morningSantaSkin); + } + + @DisplayName("인벤토리에 해당하는 타입의 아이템이 없으면 빈 목록을 조회한다.") + @Test + void find_empty_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + inventoryRepository.save(inventory(memberId, morningKillerSkin)); + + // when + List actual = inventorySearchRepository.findItems(memberId, ItemType.NIGHT); + + // then + assertThat(actual).isEmpty(); + } +} diff --git a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java new file mode 100644 index 00000000..c4d41b15 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java @@ -0,0 +1,96 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.fixture.InventoryFixture.*; +import static com.moabam.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; + +@SpringBootTest +@Transactional(readOnly = true) +class ItemSearchRepositoryTest { + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @Autowired + ItemSearchRepository itemSearchRepository; + + @DisplayName("타입으로 구매하지 않은 아이템 목록을 조회한다.") + @Test + void find_not_purchased_items_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + itemRepository.save(nightMageSkin()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(1) + .containsExactly(morningKillerSkin); + } + + @DisplayName("구매하지 않은 아이템 목록은 레벨 순으로 정렬된다.") + @Test + void find_not_purchased_items_sorted_by_level_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().unlockLevel(5).build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().unlockLevel(1).build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningKillerSkin, morningSantaSkin); + } + + @DisplayName("레벨이 같으면 가격 순으로 정렬된다.") + @Test + void find_not_purchased_items_sorted_by_price_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().bugPrice(10).build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().bugPrice(20).build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningSantaSkin, morningKillerSkin); + } + + @DisplayName("레벨과 가격이 같으면 이름 순으로 정렬된다.") + @Test + void find_not_purchased_items_sorted_by_name_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningSantaSkin, morningKillerSkin); + } +} diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java new file mode 100644 index 00000000..eee2e9a4 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -0,0 +1,64 @@ +package com.moabam.api.presentation; + +import static java.nio.charset.StandardCharsets.*; +import static java.util.Collections.*; +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.*; + +import java.util.List; + +import org.assertj.core.api.Assertions; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.ItemService; +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.dto.ItemMapper; +import com.moabam.api.dto.ItemsResponse; +import com.moabam.fixture.ItemFixture; + +@SpringBootTest +@AutoConfigureMockMvc +class ItemControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + ItemService itemService; + + @DisplayName("아이템 목록을 조회한다.") + @Test + void get_items_success() throws Exception { + // given + Long memberId = 1L; + ItemType type = ItemType.MORNING; + Item item1 = ItemFixture.morningSantaSkin().build(); + Item item2 = ItemFixture.morningKillerSkin().build(); + ItemsResponse expected = ItemMapper.toItemsResponse(List.of(item1, item2), emptyList()); + given(itemService.getItems(memberId, type)).willReturn(expected); + + // expected + String content = mockMvc.perform(get("/items") + .param("type", ItemType.MORNING.name())) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + ItemsResponse actual = objectMapper.readValue(content, ItemsResponse.class); + Assertions.assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index f18b3236..a14c21c8 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -46,7 +46,7 @@ void get_products_success() throws Exception { ProductsResponse expected = ProductMapper.toProductsResponse(List.of(product1, product2)); given(productService.getProducts()).willReturn(expected); - // when & then + // expected String content = mockMvc.perform(get("/products")) .andDo(print()) .andExpect(status().isOk()) diff --git a/src/test/java/com/moabam/fixture/InventoryFixture.java b/src/test/java/com/moabam/fixture/InventoryFixture.java new file mode 100644 index 00000000..357443dc --- /dev/null +++ b/src/test/java/com/moabam/fixture/InventoryFixture.java @@ -0,0 +1,14 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Inventory; +import com.moabam.api.domain.entity.Item; + +public class InventoryFixture { + + public static Inventory inventory(Long memberId, Item item) { + return Inventory.builder() + .memberId(memberId) + .item(item) + .build(); + } +} diff --git a/src/test/java/com/moabam/fixture/ItemFixture.java b/src/test/java/com/moabam/fixture/ItemFixture.java new file mode 100644 index 00000000..3d95487f --- /dev/null +++ b/src/test/java/com/moabam/fixture/ItemFixture.java @@ -0,0 +1,40 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemCategory; +import com.moabam.api.domain.entity.enums.ItemType; + +public class ItemFixture { + + public static final String MORNING_SANTA_SKIN_NAME = "산타 오목눈이"; + public static final String MORNING_SANTA_SKIN_IMAGE = "/item/morning_santa.png"; + public static final String MORNING_KILLER_SKIN_NAME = "킬러 오목눈이"; + public static final String MORNING_KILLER_SKIN_IMAGE = "/item/morning_killer.png"; + public static final String NIGHT_MAGE_SKIN_NAME = "메이지 부엉이"; + public static final String NIGHT_MAGE_SKIN_IMAGE = "/item/night_mage.png"; + + public static Item.ItemBuilder morningSantaSkin() { + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_SANTA_SKIN_NAME) + .image(MORNING_SANTA_SKIN_IMAGE); + } + + public static Item.ItemBuilder morningKillerSkin() { + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_KILLER_SKIN_NAME) + .image(MORNING_KILLER_SKIN_IMAGE); + } + + public static Item nightMageSkin() { + return Item.builder() + .type(ItemType.NIGHT) + .category(ItemCategory.SKIN) + .name(NIGHT_MAGE_SKIN_NAME) + .image(NIGHT_MAGE_SKIN_IMAGE) + .build(); + } +}