본문 바로가기

Server Programming/Spring Boot Full-Stack Programming

[스프링 풀스택 클론 코딩 - 회원가입] (1-10) 회원 가입 인증 메일 확인 테스트 및 리팩토링

반응형

H2 DB의 경우 애플리케이션 종료시 초기화 된다.

 

1. 테스트 코드 작성

(1) 입력값 오류

(2) 입력값 정상

 

2. 리팩토링

(1) 코드의 위치 수정

(2) 코드의 가독성 높이기

(3) 코드의 메서드화


1. 입력값 오류, 입력값 정상 테스트 작성

-> 마찬가지로, 토큰값 생성하고 @Transactional로 DB싱크 맞춘 후, 동일 여부 확인 

package com.demo.account;

import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

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.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import com.demo.domain.Account;

@Transactional
//테스트를 위한 어노테이션 -> 스프링부트테스트, 자동설정목업 MVC
@SpringBootTest @AutoConfigureMockMvc
//사용하면 서블릿이 뜨고, @AutoConfigureWebClient  @AutoConfigureTestClient를 이용하면 테스트 가능 
//@SpringBootTest (webEnvironment =WebEnvironment.DEFINED_PORT)
//@SpringBootTest (webEnvironment =WebEnvironment.NONE)
//@SpringBootTest (webEnvironment =WebEnvironment.RANDOM_PORT)

//테스트 코드가 필요한 이유
//코드를 변경한 이후에 작성한 코드가 오류를 발생시키지 않았다는 것을 확인하기 위해
//모놀리틱 아키텍처의 경우, 작은 변화가 코드 전체의 오류에 영향을 끼치기 쉽기 때문에 테스트 코드가 필요하다.
class AccountControllerTest {
	
	
	//의존성 주입
	@Autowired private MockMvc mockMvc;
	
	@Autowired private AccountRepository accountRepository;

	//이메일 보내는지 확인할 목업빈 -> 메일샌더가 메일을 보냈는지 여부 확인
	@MockBean
	JavaMailSender javaMailSender;

	@DisplayName("회원 가입 화면이 보이는지 테스트")
	@Test
	void signUpForm() throws Exception { //아래의 응답이 아닐경우 예외처리
		mockMvc.perform(get("/sign-up")) //회원가입 요청에서
		.andDo(print()) //실제 웹사이트 출력 -> 타임리프이기 때문에
		.andExpect(status().isOk()) //보이는지 -> 즉, 상태가 정상일때 (200일때)
		.andExpect(view().name("account/sign-up")) //뷰의 이름이 account/sign-up이 맞는지
		.andExpect(model().attributeExists("signUpForm"));
		
	}
	@DisplayName("회원 가입 처리 - 입력값 오류")
	@Test
	void signUpSubmit_with_wrong_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email..")
				.param("password", "12345")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().isOk())
		//status OK 확인 후 -> 다시 회원가입페이지 출력
		.andExpect(view().name("account/sign-up"));
	}
	//403출력하는데 -> 스프링 시큐리티에서 authorize만 확인하는데
	//-> CSRF 토큰에 의해 타사이트에서 폼데이터를 보내는 공격을 방지하기 위해
	//타임리프 탬플릿에서 form을 보낼 경우 토큰값을 확인하는데, 토큰값이 다르다는 것을 확인
	//-> csrf 토큰을 삽입한다.

	@DisplayName("회원 가입 처리 - 입력값 정상")
	@Test
	void signUpSubmit_with_correct_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email@gmail.com")
				.param("password", "12345678")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().is3xxRedirection())		//redirection 응답 확인
		.andExpect(view().name("redirect:/"));
		
		//비밀번호 인코딩 확인 -> id를 가져와서 확인 
		Account account = accountRepository.findByEmail("email@gmail.com");
		
		//null이 아닌지 확인 -> assertTrue와 같은 것이 된다.
		assertNotNull(account);
		//입력한 값과 같지 않은지 확인
		assertNotEquals(account.getPassword(), "12345678");
		//토큰 null 오류 발생  -> 디버깅 토큰이 널인지 아닌지 확인
		assertNotNull(account.getEmailCheckToken());
		
		//유저 조회를 위한 의존성 주입 필요-> AccountRepository
		//junit 의존성 추가
//		assertTrue(accountRepository.existsByEmail("email@gmail.com"));
		//-> 패스워드 인코딩 확인하기 위해 직접 Email가져오기 때문에 주석처리
		
		then(javaMailSender).should().send(any(SimpleMailMessage.class));
	}

	@DisplayName("인증 메일 확인 - 입력값 오류 ")
	@Test
	void checkEmailToken_with_wrong_input() throws Exception{
		mockMvc.perform(get("/check-email-token")
				.param("token", "lssdejelskak")
				.param("email", "email@email.com"))
				.andExpect(status().isOk())
				.andExpect(model().attributeExists("error"))
				.andExpect(view().name("account/checked-email"));
		//model객체의 attribute에 errors담는지, 해당 뷰 네임이 checked-mail인지 확인
	}
	
	@DisplayName("인증 메일 확인 - 입력값 정상")
	@Test
	void checkEmailToken() throws Exception{
		//오류와 달리 정상의 경우 입력값을 저장하는 객체가 필요하다
		 
		//빌더 패턴는 생성자 패턴의 단점을 해결하는 패턴으로
		//		복잡한 객체의 생성 과정 및 표현 방법을 분리해 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게 하는 패턴
		//		빌더는 생성자 또는 정적 메소드와 비교하여 테스트용 객체를 생성할 때 용이하게 해주고, 불필요한 코드의 양을 줄이는 등의 이점을 안겨준다.
		
		
		Account account = Account.builder()
				.email("test@email.com")
				.password("12345678")
				.nickname("jihun")
				.build();
		 Account newAccount = accountRepository.save(account);
		 //토큰 비교를 위해 토큰 생성
		 newAccount.generateEmailCheckToken();
		// Repository의 내부 구현체를 자동으로 생성시켜 주기 때문에 별도의 구현체를 따로 생성하지 않아도 된다.
		//임시로 사용하기에 편하다 따라서, 존재여부 조회시, 단위 테스트시, 임시로 저장하는데 사용한다.
		
		
		mockMvc.perform(get("/check-email-token")
				//이 경우에는 해당 이메일이 가지고 있는 정보를 가져와야하므로
				.param("token", newAccount.getEmailCheckToken())
				.param("email", newAccount.getEmail()))
				.andExpect(status().isOk())
				.andExpect(model().attributeDoesNotExist("error"))
				//해당 몇번째 회원인지와 닉네임 리턴
				.andExpect(model().attributeExists("nickname"))
				.andExpect(model().attributeExists("numberOfUser"))
				.andExpect(view().name("account/checked-email"));
		//model객체의 attribute에 errors담는지, 해당 뷰 네임이 checked-mail인지 확인
		//마찬가지로 토큰 값을 DB에 싱크하기 위해 @Transactional이 필요하다
		
	}
	
}

 

2. 컨트롤러 리팩토링

package com.demo.account;

import java.time.LocalDateTime;

import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import com.demo.domain.Account;

import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class AccountController {

	private final SignUpFormValidator signUpFormValidator;
	//계정 리포지토리와 메일 샌더는 서비스로 이전
	private final AccountService accountService; 
	//이메일 인증 메일 발송을 위한, accountrepository 사용 -> 리파지토리를 도메인으로 여겨서 자유롭게 사용
	private final AccountRepository accountRepository;


	@InitBinder("signUpForm")
	public void initBinder(WebDataBinder webDataBinder) {
		webDataBinder.addValidators(signUpFormValidator);
	}

	// sign-up 페이지에 연결된다면
	@GetMapping("/sign-up")
	public String signUpForm(Model model) {
		// model.addAttribute(new SignUpForm()); 생략 가능
		model.addAttribute("signUpForm", new SignUpForm());
		return "account/sign-up";
	}
	// 스프링부트 자동설정에 의해
	// templates에 존재하는 view인 account/sign-up을 리턴한다.

	@PostMapping("/sign-up") // 복합객체는 본디 ModelAttribute로 받지만, 생략가능
	public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
		if (errors.hasErrors()) {
			return "account/sign-up";
		}
		// SignUpForm 도메인에 Valid를 위한 처리 필요

//		@InitBinder를 이용해 대체 
//		signUpFormValidator.validate(signUpForm, errors);
//		if (errors.hasErrors()) {
//			return "account/sign-up";
//		}
		// -> 자동으로 SignUpForm 검증을 한다.

		//이메일 보내는 작업을 컨트롤러가 모르도록 서비스 쪽으로 이전 -> 서비스에서 private으로 수행
		accountService.processNewAccount(signUpForm);
		
		return "redirect:/";

	}
	
	//이메일 인증 확인 -> 이메일, 토큰 값 둘다 일치해야함 -> 화면에 전달할 모델도 파라미터로 전송
	@GetMapping("/check-email-token")
	public String checkEmailToken(String token, String email, Model model) {
		Account account = accountRepository.findByEmail(email);
		String view = "account/checked-email";
		//같은 링크를 리턴하지만, 담은 정보는 각각 다르다. 
		//해당 뷰에서 error를 담아서 왔을 경우와 setEmailVerified가 true를 담아서 왔을 때를 구분해서 처리
		
		//리파지토리를 도메인 계층으로 생각해, 레이어계층에서의 리파지토리 참조를 자유롭게 한다.
		//반대로, 레이어 계층인, 서비스나 컨트롤러를 도메인 계층에서 참조하는 것은 지양한다.
		//1단계에서 이메일의 존재여부를 판단
		//2단계에서는 해당하는 이메일의 토큰값 동일 여부를 확인한다.
		if(account==null) {
			//해당 이메일이 존재하지 않을 때 오류 발생
			//정보제공을 줄이기 위해 -> 서버단에서만 wrong.email이라는 것을 명시하고, 리턴은 하지않는다.
			model.addAttribute("error", "wrong.email");
			return view;
		}
		
		//토큰이 같지 않다면-> 하나의 메서드로
		if(!account.isValidToken(token)) {
			model.addAttribute("error", "wrong.token");
			return view;
		}
		
		//해당 이메일 검증을 참으로 설정하고, 가입 정보(시간정보)와 몇번째 회원인지 리턴한다.
		account.setEmailVerified(true);
		account.setJoinedAt(LocalDateTime.now());
		model.addAttribute("numberOfUser", accountRepository.count());
		model.addAttribute("nickname", account.getNickname());
		return view;
		
	}

}

Account

package com.demo.domain;

import java.time.LocalDateTime;
import java.util.UUID;

import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Lob;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

//도메인 생성을 위한 어노테이션
@Entity

// id만 이용해, equals확인 -> equals 메서드 오버라이딩
@Getter @Setter @EqualsAndHashCode(of = "id")

//기본 생성자가 필요한 상태에서 @Builder사용하기 위해 
@Builder @AllArgsConstructor @NoArgsConstructor
public class Account {

	// 기본키와 생성 전략
	@Id	@GeneratedValue
	private Long id;
	
	
	//로그인 방식에서 이메일과 닉네임을 이용한 방식 지원하기 위해
	@Column(unique =true) //중복 방지
	private String email;
	@Column(unique =true)
	private String nickname;
	
	private String password;
	
	//이메일 인증 관련 참거짓판단
	private boolean emailVerified;

	//이메일 검증 토큰 값
	private String emailCheckToken;
	
	//회원가입날짜 변수
	private LocalDateTime joinedAt;
	
	private String bio;
	
	private String url;
	
	private String occpation;
	
	private String location;
	
	//이미지파일은 varchar 데이터형식보다 크기가 커서 사용하는 어노테이션
	//로딩 시간을 설정하는 어노테이션 즉시 로딩과 지연 로딩이 존재
	//즉시 로딩 EAGER, 지연 로딩 LAZY
	@Lob @Basic(fetch = FetchType.EAGER)
	private String profileImage;
	
	//생성, 가입, 갱신정보 알림 설정 -> Email, Web, 둘다
	private boolean studyCreatedByEmail;
	
	private boolean studyCreatedByWeb;
	
	private boolean studyEnrollmentResultByEmail;
	
	private boolean studyEnrollmentResultByWeb;
	
	private boolean studyUpdatedByEmail;
	
	private boolean studyUpdatedByWeb;
	
	//이메일 인증 위한 토큰 랜덤 생성 
	public void generateEmailCheckToken() {
		this.emailCheckToken =UUID.randomUUID().toString();
	}

	public void completeSignUp() {
		// TODO Auto-generated method stub
		//해당 이메일 검증을 참으로 설정하고, 가입 정보(시간정보)와 몇번째 회원인지 리턴한다.
				this.emailVerified=true;
				this.joinedAt=LocalDateTime.now();
	}
    
	public boolean isValidToken(String token) {
		// TODO Auto-generated method stub
		return this.emailCheckToken.equals(token);
	}
}

 

 

반응형