ADD AccountRepository update & TokenVerifier HTTP API

- MybatisAccountRepository update 구현
- RegisterAccountApi -> AccountCommandApi 변경
- 테스트용 TestAccountRepositoryStub 분리
This commit is contained in:
JiwonDev
2021-09-04 17:17:49 +09:00
parent a3b5f5c11f
commit b0fe8213fb
11 changed files with 340 additions and 186 deletions

View File

@@ -29,4 +29,8 @@ public class AccountFacade {
publisher.publishEvent(new RegisterAccountEvent(entity));
return translator.toResponse(entity);
}
public boolean verify(String token, String email) {
return false;
}
}

View File

@@ -7,4 +7,7 @@ public interface AccountRepository {
boolean existsByNickname(String nickname);
Account save(Account entity);
Account update(Account entity);
}

View File

@@ -22,6 +22,16 @@ public final class MybatisAccountRepository implements AccountRepository, Accoun
return result != 0;
}
@Override
public Account update(Account entity) {
int result = template.update(COMMAND_NAMESPACE + "update", entity);
if (result != 1) {
throw new RuntimeException(
String.format("There was a problem updating the object : %s", entity));
}
return findByEmail(entity.getEmail());
}
@Override
public boolean existsByNickname(String nickname) {
int result = template.selectOne(COMMAND_NAMESPACE + "existsByNickname", nickname);

View File

@@ -0,0 +1,53 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class AccountCommandApi {
private final AccountFacade accountFacade;
public AccountCommandApi(AccountFacade accountFacade) {
this.accountFacade = accountFacade;
}
@PostMapping("/api/accounts")
public ResponseEntity<AccountResponse> register(
@RequestBody @Valid RegisterAccountRequest request) {
return ResponseEntity.ok(accountFacade.register(request));
}
/**
* 회원가입 이메일 검증 컨트롤러
* 임시로 "http://localhost:3000/login"로 리다이렉트 되도록 설정.
*/
@GetMapping("/api/accounts/authorize")
public ResponseEntity<Void> verify(
@RequestParam String token,
@RequestParam String email) throws Exception {
var result = accountFacade.verify(token, email);
if (!result) {
return ResponseEntity.badRequest().build();
}
var uri = new URI("http://localhost:3000/login");
var header = new HttpHeaders();
header.setLocation(uri);
return new ResponseEntity<>(header, HttpStatus.SEE_OTHER);
}
}

View File

@@ -1,30 +0,0 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import javax.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class RegisterAccountApi {
private final AccountFacade accountFacade;
public RegisterAccountApi(AccountFacade accountFacade) {
this.accountFacade = accountFacade;
}
@PostMapping("/api/accounts")
public ResponseEntity<AccountResponse> register(
@RequestBody @Valid RegisterAccountRequest request) {
return ResponseEntity.ok(accountFacade.register(request));
}
}

View File

@@ -2,6 +2,18 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yam.app.account.domain.AccountRepository">
<update id="update" parameterType="com.yam.app.account.domain.Account">
UPDATE ACCOUNT
SET email = #{email},
email_check_token = #{emailCheckToken},
email_check_token_generated_at = #{emailCheckTokenGeneratedAt},
email_verified = #{emailVerified},
nickname = #{nickname},
password = #{password},
withdraw = #{withdraw}
WHERE id = #{id}
LIMIT 1
</update>
<select id="existsByEmail" parameterType="String" resultType="int">
SELECT COUNT(email)

View File

@@ -0,0 +1,45 @@
package com.yam.app.account.domain;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
public final class FakeAccountRepository implements AccountRepository, AccountReader {
private final Map<Long, Account> data = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong();
@Override
public Account findByEmail(String email) {
return data.values().stream()
.filter(account -> email.equals(account.getEmail()))
.findAny()
.orElse(null);
}
@Override
public boolean existsByEmail(String email) {
return data.values().stream()
.anyMatch(account -> account.getEmail().equals(email));
}
@Override
public Account update(Account entity) {
return data.putIfAbsent(entity.getId(), entity);
}
@Override
public boolean existsByNickname(String nickname) {
return data.values().stream()
.anyMatch(account -> account.getNickname().equals(nickname));
}
@Override
public Account save(Account entity) {
entity.setId(idGenerator.incrementAndGet());
data.put(entity.getId(), entity);
return entity;
}
}

View File

@@ -7,9 +7,6 @@ import com.yam.app.account.application.RegisterAccountCommand;
import com.yam.app.account.domain.PasswordEncrypterTest.PasswordEncrypterStub;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
@@ -20,7 +17,7 @@ class RegisterAccountProcessorTest {
@TestFactory
@DisplayName("회원가입 시나리오")
Collection<DynamicTest> register_account_scenarios() {
var repository = new AccountRepositoryStub();
var repository = new FakeAccountRepository();
var processor = new RegisterAccountProcessor(repository, new PasswordEncrypterStub());
return Arrays.asList(
DynamicTest.dynamicTest("회원가입에 성공한다.", () -> {
@@ -51,31 +48,4 @@ class RegisterAccountProcessorTest {
})
);
}
private static class AccountRepositoryStub implements AccountRepository {
private final Map<Long, Account> data = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong();
@Override
public boolean existsByEmail(String email) {
return data.values().stream()
.anyMatch(account -> account.getEmail().equals(email));
}
@Override
public boolean existsByNickname(String nickname) {
return data.values().stream()
.anyMatch(account -> account.getNickname().equals(nickname));
}
@Override
public Account save(Account entity) {
if (entity.getId() == null) {
entity.setId(idGenerator.incrementAndGet());
data.put(entity.getId(), entity);
}
return data.putIfAbsent(entity.getId(), entity);
}
}
}

View File

@@ -53,4 +53,16 @@ class MybatisAccountRepositoryTest {
assertThat(account.getId()).isEqualTo(2);
}
@Test
@DisplayName("Account 객체 갱신 테스트")
void update() {
Account account = accountRepository.save(
Account.of("jiwon22@gmail.com", "jiwon2", "password!"));
account.completeRegister(); // boolean emailVerified = true
Account updatedAccount = accountRepository.update(account);
assertThat(updatedAccount.isEmailVerified()).isTrue();
}
}

View File

@@ -0,0 +1,200 @@
package com.yam.app.account.presentation;
import static org.mockito.ArgumentMatchers.anyString;
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.javaunit.autoparams.AutoSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
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.test.web.servlet.MockMvc;
@DisplayName("Account Command HTTP API")
@WebMvcTest(AccountCommandApi.class)
class AccountCommandApiTests {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private AccountFacade accountFacade;
@Nested
@DisplayName("이메일 검증 HTTP API")
class EmailVerifiedApi {
@ParameterizedTest
@NullAndEmptySource
@DisplayName("비어있거나 null인 토큰과 이메일 정보로 검증 요청을 보낸 경우 400 HTTP Code를 리턴한다.")
void http_param_is_empty_or_null(String args) throws Exception {
// Arrange
// Act
when(accountFacade.verify(args, args)).thenReturn(false);
final var actions = mockMvc.perform(get("/api/accounts/authorize")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.param("token", "emailTOken")
.param("email", "jiwonDev@gmail.com")
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("유효하지 않은 토큰과 이메일 정보로 검증 요청을 보낸 경우 400 HTTP Code를 리턴한다.")
void http_param_is_not_valid() throws Exception {
// Arrange
// Act
when(accountFacade.verify(anyString(), anyString())).thenReturn(false);
final var actions = mockMvc.perform(get("/api/accounts/authorize")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.param("token", "emailTOken")
.param("email", "jiwonDev@gmail.com")
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("토큰과 이메일 정보로 검증 요청을 보낸 경우 303 HTTP Code를 리턴한다.")
void valid_success() throws Exception {
// Arrange
// Act
when(accountFacade.verify(anyString(), anyString())).thenReturn(true);
final var actions = mockMvc.perform(get("/api/accounts/authorize")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.param("token", "emailTOken")
.param("email", "jiwonDev@gmail.com")
);
// Assert
actions
.andDo(print())
.andExpect(status().isSeeOther());
}
}
@Nested
@DisplayName("회원가입 등록 HTTP API")
class RegisterApi {
@Test
@DisplayName("회원가입에 적절한 파라미터가 입력되고 회원가입이 성공한다.")
void register_success() throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail("msolo021015@gmail.com");
request.setNickname("rebwon");
request.setPassword("password!");
// Act
when(accountFacade.register(request)).thenReturn(
new AccountResponse(1L, "msolo021015@gmail.com", "rebwon"));
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
}
@Test
@DisplayName("Accept와 Content-Type을 지정하지 않아, HttpMediaTypeNotSupportedException 발생.")
void register_account_api_not_use_accept_header_and_content_type() throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail("msolo021015@gmail.com");
request.setNickname("rebwon");
request.setPassword("password!");
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andExpect(status().isUnsupportedMediaType());
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("HTTP 입력이 Null이거나 Emtpy인 경우 검증에 실패하여 에러를 응답한다.")
void register_http_parameter_is_null_and_empty(String arg) throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail(arg);
request.setNickname(arg);
request.setPassword(arg);
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
@ParameterizedTest
@AutoSource
@DisplayName("HTTP 입력 파라미터가 이메일, 비밀번호 검증에 실패하여 에러를 응답한다.")
void register_http_parameter_is_invalid_email_and_password(String arg) throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail(arg);
request.setNickname(arg);
request.setPassword(arg);
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
}
}

View File

@@ -1,125 +0,0 @@
package com.yam.app.account.presentation;
import static org.mockito.Mockito.when;
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.javaunit.autoparams.AutoSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
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.test.web.servlet.MockMvc;
@DisplayName("회원가입 등록 HTTP API")
@WebMvcTest(RegisterAccountApi.class)
class RegisterAccountApiTests {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private AccountFacade accountFacade;
@Test
@DisplayName("회원가입에 적절한 파라미터가 입력되고 회원가입이 성공한다.")
void register_success() throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail("msolo021015@gmail.com");
request.setNickname("rebwon");
request.setPassword("password!");
// Act
when(accountFacade.register(request)).thenReturn(
new AccountResponse(1L, "msolo021015@gmail.com", "rebwon"));
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
}
@Test
@DisplayName("Accept 헤더와 Content-Type을 지정해주지 않았으므로, HttpMediaTypeNotSupportedException이 발생한다.")
void register_account_api_not_use_accept_header_and_content_type() throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail("msolo021015@gmail.com");
request.setNickname("rebwon");
request.setPassword("password!");
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andExpect(status().isUnsupportedMediaType());
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("HTTP 입력이 Null이거나 Emtpy인 경우 검증에 실패하여 에러를 응답한다.")
void register_http_parameter_is_null_and_empty(String arg) throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail(arg);
request.setNickname(arg);
request.setPassword(arg);
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
@ParameterizedTest
@AutoSource
@DisplayName("HTTP 입력 파라미터가 이메일, 비밀번호 검증에 실패하여 에러를 응답한다.")
void register_http_parameter_is_invalid_email_and_password(String arg) throws Exception {
// Arrange
var request = new RegisterAccountRequest();
request.setEmail(arg);
request.setNickname(arg);
request.setPassword(arg);
// Act
final var actions = mockMvc.perform(post("/api/accounts")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// Assert
actions
.andDo(print())
.andExpect(status().isBadRequest());
}
}