diff --git a/.editorconfig b/.editorconfig index 76645d3..358e23d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,15 @@ charset = utf-8 # [newline-eof] insert_final_newline = true +# Ignore +[*.log] +charset = unset +end_of_line = unset +insert_final_newline = unset +trim_trailing_whitespace = unset +indent_style = unset +indent_size = unset + [*.bat] end_of_line = crlf @@ -23,4 +32,4 @@ tab_width = 4 trim_trailing_whitespace = true # [line-length-100] -max_line_length = 100 \ No newline at end of file +max_line_length = 100 diff --git a/build.gradle b/build.gradle index 82e5efa..d285240 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,7 @@ jacocoTestCoverageVerification { enabled = true element = 'CLASS' excludes = [ + "*.*ArgumentResolver", "*.YouAndMeApplication", "*.*Request", "*.*Response", diff --git a/src/main/java/com/yam/app/account/application/AccountFacade.java b/src/main/java/com/yam/app/account/application/AccountFacade.java index 86ce1d8..ca2342e 100644 --- a/src/main/java/com/yam/app/account/application/AccountFacade.java +++ b/src/main/java/com/yam/app/account/application/AccountFacade.java @@ -1,11 +1,13 @@ package com.yam.app.account.application; +import com.yam.app.account.domain.AccountPrincipal; import com.yam.app.account.domain.ConfirmRegisterAccountProcessor; import com.yam.app.account.domain.LoginAccountProcessor; import com.yam.app.account.domain.RegisterAccountEvent; import com.yam.app.account.domain.RegisterAccountProcessor; import com.yam.app.account.presentation.AccountResponse; import com.yam.app.account.presentation.ConfirmRegisterAccountRequestCommand; +import com.yam.app.account.presentation.GetSessionAccountCommand; import com.yam.app.account.presentation.LoginAccountRequestCommand; import com.yam.app.account.presentation.RegisterAccountRequestCommand; import org.springframework.context.ApplicationEventPublisher; @@ -20,16 +22,19 @@ public class AccountFacade { private final ApplicationEventPublisher publisher; private final ConfirmRegisterAccountProcessor confirmRegisterProcessor; private final LoginAccountProcessor loginProcessor; + private final AccountPrincipal accountPrincipal; public AccountFacade(RegisterAccountProcessor registerProcessor, AccountTranslator translator, ApplicationEventPublisher publisher, ConfirmRegisterAccountProcessor confirmRegisterProcessor, - LoginAccountProcessor loginProcessor) { + LoginAccountProcessor loginProcessor, + AccountPrincipal accountPrincipal) { this.registerProcessor = registerProcessor; this.translator = translator; this.publisher = publisher; this.confirmRegisterProcessor = confirmRegisterProcessor; this.loginProcessor = loginProcessor; + this.accountPrincipal = accountPrincipal; } @Transactional @@ -48,7 +53,13 @@ public class AccountFacade { confirmRegisterProcessor.registerConfirm(request.getToken(), request.getEmail()); } + @Transactional(readOnly = true) public void login(LoginAccountRequestCommand request) { loginProcessor.login(request.getEmail(), request.getPassword()); } + + @Transactional(readOnly = true) + public AccountResponse getSessionAccount(GetSessionAccountCommand command) { + return translator.toResponse(accountPrincipal.getAccount(command.getEmail())); + } } diff --git a/src/main/java/com/yam/app/account/domain/AccountPrincipal.java b/src/main/java/com/yam/app/account/domain/AccountPrincipal.java new file mode 100644 index 0000000..6f61dc3 --- /dev/null +++ b/src/main/java/com/yam/app/account/domain/AccountPrincipal.java @@ -0,0 +1,16 @@ +package com.yam.app.account.domain; + +public final class AccountPrincipal { + + private final AccountReader accountReader; + + public AccountPrincipal(AccountReader accountReader) { + this.accountReader = accountReader; + } + + public Account getAccount(String email) { + return accountReader.findByEmail(email) + .orElseThrow(IllegalStateException::new); + } + +} diff --git a/src/main/java/com/yam/app/account/infrastructure/AppConfiguration.java b/src/main/java/com/yam/app/account/infrastructure/AppConfiguration.java index 4586f68..f06b56c 100644 --- a/src/main/java/com/yam/app/account/infrastructure/AppConfiguration.java +++ b/src/main/java/com/yam/app/account/infrastructure/AppConfiguration.java @@ -1,5 +1,6 @@ package com.yam.app.account.infrastructure; +import com.yam.app.account.domain.AccountPrincipal; import com.yam.app.account.domain.AccountReader; import com.yam.app.account.domain.AccountRepository; import com.yam.app.account.domain.ConfirmRegisterAccountProcessor; @@ -69,4 +70,9 @@ public class AppConfiguration { return new LoginAccountProcessor(accountReader, passwordEncrypter); } + @Bean + public AccountPrincipal accountPrincipal(AccountReader accountReader) { + return new AccountPrincipal(accountReader); + } + } diff --git a/src/main/java/com/yam/app/account/presentation/AccountQueryApi.java b/src/main/java/com/yam/app/account/presentation/AccountQueryApi.java index 5c052d6..2bee943 100644 --- a/src/main/java/com/yam/app/account/presentation/AccountQueryApi.java +++ b/src/main/java/com/yam/app/account/presentation/AccountQueryApi.java @@ -5,6 +5,8 @@ import javax.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -35,4 +37,16 @@ public final class AccountQueryApi { LoginSessionUtils.setLoginAccountEmail(request.getEmail()); return ResponseEntity.ok().build(); } + + @GetMapping("/api/accounts/me") + public ResponseEntity getAccount( + @LoginAccount GetSessionAccountCommand request) { + return ResponseEntity.ok(accountFacade.getSessionAccount(request)); + } + + // LoginAccountMethodArgumentResolver 테스트를 위해 임시로 만든 @ExceptionHandler + @ExceptionHandler + public ResponseEntity defaultException(IllegalStateException e) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } } diff --git a/src/main/java/com/yam/app/account/presentation/GetSessionAccountCommand.java b/src/main/java/com/yam/app/account/presentation/GetSessionAccountCommand.java new file mode 100644 index 0000000..3e10646 --- /dev/null +++ b/src/main/java/com/yam/app/account/presentation/GetSessionAccountCommand.java @@ -0,0 +1,14 @@ +package com.yam.app.account.presentation; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public final class GetSessionAccountCommand { + + @NotBlank + @Email + private String email; + +} diff --git a/src/main/java/com/yam/app/account/presentation/LoginAccount.java b/src/main/java/com/yam/app/account/presentation/LoginAccount.java new file mode 100644 index 0000000..735076c --- /dev/null +++ b/src/main/java/com/yam/app/account/presentation/LoginAccount.java @@ -0,0 +1,12 @@ +package com.yam.app.account.presentation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginAccount { + +} diff --git a/src/main/java/com/yam/app/account/presentation/LoginAccountMethodArgumentResolver.java b/src/main/java/com/yam/app/account/presentation/LoginAccountMethodArgumentResolver.java new file mode 100644 index 0000000..b20e3d8 --- /dev/null +++ b/src/main/java/com/yam/app/account/presentation/LoginAccountMethodArgumentResolver.java @@ -0,0 +1,36 @@ +package com.yam.app.account.presentation; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public final class LoginAccountMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginAccount.class) + && GetSessionAccountCommand.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpSession session = ((HttpServletRequest) webRequest.getNativeRequest()) + .getSession(false); + + if (session == null) { + throw new IllegalStateException(); + } + + var command = new GetSessionAccountCommand(); + command.setEmail((String) session.getAttribute(LoginSessionUtils.LOGIN_ACCOUNT_EMAIL)); + return command; + + } +} diff --git a/src/main/java/com/yam/app/account/presentation/WebConfiguration.java b/src/main/java/com/yam/app/account/presentation/WebConfiguration.java new file mode 100644 index 0000000..ec717a7 --- /dev/null +++ b/src/main/java/com/yam/app/account/presentation/WebConfiguration.java @@ -0,0 +1,23 @@ +package com.yam.app.account.presentation; + +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + + private final LoginAccountMethodArgumentResolver loginAccountMethodArgumentResolver; + + public WebConfiguration( + LoginAccountMethodArgumentResolver loginAccountMethodArgumentResolver) { + this.loginAccountMethodArgumentResolver = loginAccountMethodArgumentResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginAccountMethodArgumentResolver); + + } +} diff --git a/src/main/resources/sql/ddl.sql b/src/main/resources/sql/ddl.sql index d0b0a59..f07d787 100644 --- a/src/main/resources/sql/ddl.sql +++ b/src/main/resources/sql/ddl.sql @@ -23,4 +23,7 @@ alter table account insert into account(email, email_check_token, email_check_token_generated_at, email_verified, joined_at, last_modified_at, nickname, password, withdraw, role) values ('jiwonDev@gmail.com', 'emailchecktoken', now(), false, now(), now(), 'jiwon', 'password!', + false, 'DEFAULT'), + ('loginCheck@gmail.com', 'emailchecktoken1', now(), true, now(), now(), 'loginCheck', + '$2a$10$EqbMbYB0vcZnuA5CClqa9uiLDnjA6pCjxn208ZchzA2q3ofqnkhcq', false, 'DEFAULT'); diff --git a/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java b/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java index 292e0ed..b1eb529 100644 --- a/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java +++ b/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java @@ -8,13 +8,18 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yam.app.account.presentation.LoginAccountRequestCommand; +import com.yam.app.account.presentation.LoginSessionUtils; import com.yam.app.account.presentation.RegisterAccountRequestCommand; +import org.junit.jupiter.api.AfterEach; +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.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -24,12 +29,23 @@ import org.springframework.test.web.servlet.MockMvc; @ActiveProfiles("test") final class AccountIntegrationTests { + private MockHttpSession session; @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; + @BeforeEach + public void setUp() { + session = new MockHttpSession(); + session.setAttribute(LoginSessionUtils.LOGIN_ACCOUNT_EMAIL, "loginCheck@gmail.com"); + } + + @AfterEach + public void clean() { + session.clearAttributes(); + } + @Test @DisplayName("새로운 계정을 등록하는 회원가입 시나리오") void new_account_request_in_register_correctly() throws Exception { @@ -73,4 +89,44 @@ final class AccountIntegrationTests { .andExpect(redirectedUrl("http://localhost:3000/login")); } + @Test + @DisplayName("로그인 요청을 성공하여 서버의 세션 등록을 완료하는 시나리오") + void login_success_get_session() throws Exception { + //Arrange + LoginAccountRequestCommand request = new LoginAccountRequestCommand(); + request.setEmail("loginCheck@gmail.com"); + request.setPassword("password!"); + + //Act + final var actions = mockMvc.perform(post("/api/accounts/login") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + //Assert + actions + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("로그인한 회원이 세션에서 자신의 정보를 받아오는 시나리오") + void login_member_get_account_session_request() throws Exception { + //Act + final var actions = mockMvc.perform(get("/api/accounts/me") + .session(session) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)); + + //Assert + actions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.email").isString()) + .andExpect(jsonPath("$.nickname").isString()); + + } + } diff --git a/src/test/java/com/yam/app/account/presentation/AccountQueryApiTest.java b/src/test/java/com/yam/app/account/presentation/AccountQueryApiTest.java index 4f29c5f..0933757 100644 --- a/src/test/java/com/yam/app/account/presentation/AccountQueryApiTest.java +++ b/src/test/java/com/yam/app/account/presentation/AccountQueryApiTest.java @@ -2,12 +2,17 @@ package com.yam.app.account.presentation; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import com.yam.app.account.application.AccountFacade; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,6 +23,7 @@ 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.MediaType; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; @DisplayName("Account Query HTTP API") @@ -35,6 +41,58 @@ class AccountQueryApiTest { @DisplayName("Login HTTP API") class LoginApi { + private final String loggedInEmail = "loginCheck@gmail.com"; + private MockHttpSession session; + + @BeforeEach + public void setUp() { + session = new MockHttpSession(); + session.setAttribute(LoginSessionUtils.LOGIN_ACCOUNT_EMAIL, loggedInEmail); + } + + @AfterEach + public void clean() { + session.clearAttributes(); + } + + @Test + @DisplayName("세션이 없는 상태로 Account 정보를 요청하면 401 에러를 반환한다.") + void no_current_session_account_request() throws Exception { + //Act + final var actions = mockMvc.perform(get("/api/accounts/me") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)); + + //Assert + actions + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("세션이 있는 상태로 Account 정보를 요청하면 200과 사용자 정보를 반환한다.") + void no_current_session_account_reqsuest() throws Exception { + //Arrange + var command = new GetSessionAccountCommand(); + command.setEmail(loggedInEmail); + when(accountFacade.getSessionAccount(command)).thenReturn( + new AccountResponse(1L, loggedInEmail, "hello")); + + //Act + final var actions = mockMvc.perform(get("/api/accounts/me") + .session(session) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON)); + + //Assert + actions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.email").isString()) + .andExpect(jsonPath("$.nickname").isString()); + } + @Test @DisplayName("정상적인 이메일과 비밀번호를 보내 로그인에 성공하고 200을 반환한다.") void login_success() throws Exception { @@ -45,7 +103,7 @@ class AccountQueryApiTest { doNothing().when(accountFacade).login(request); //Act - var actions = mockMvc.perform(post("/api/accounts/login") + final var actions = mockMvc.perform(post("/api/accounts/login") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) @@ -135,7 +193,7 @@ class AccountQueryApiTest { request.setPassword(args); //Act - var actions = mockMvc.perform(post("/api/accounts/login") + final var actions = mockMvc.perform(post("/api/accounts/login") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))