이전 진행 상황
- Comment 패키지 Controller 개발
- Photo 패키지 Controller 개발
- User
- Post
- Comment
- Photo
- Global
User
백엔드 통합 테스트가 개발되면서 많은 수정 사항이 생겼습니다. 기본적인 통합 테스트 환경으로 h2 데이터베이스를 메모리 모드로 MySql 모드를 사용한 채 수행하였고 추천/비추천에 사용되는 Redis의 경우 Embedded-Redis를 사용해 진행하였습니다. 그리고 현재 Thymeleaf가 개발되기 전이므로 테스트 환경에선 Thymeleaf 템플릿을 비활성화하여 렌더링 되지 않게 한 뒤 테스트 하였습니다.
변경 사항
- UserFormController 로그인 예외 처리 추가
추가 사항
- UserControllerTest 추가
변경 내용
class UserFormController - 수정
Spring Security가 로그인을 처리하지만 error 발생 시 로그인 오류 메시지 처리를 위해 error 정보를 model에 담아 넘기게 되었습니다. 넘어간 에러를 통해 웹에서 알림 창으로 비동기 처리를 하게 됩니다.
//로그인 -> SpringSecurity 처리
@GetMapping("/login")
public String loginForm(@RequestParam(required = false) String error, Model model) {
if (error != null) {
model.addAttribute("errorMessage", "아이디 또는 비밀번호가 일치하지 않습니다.");
}
return "user/loginForm";
}
추가 내용
class UserControllerTest - 추가
UserController에 대한 통합 테스트를 수행하는 코드입니다. MockMvc를 사용해 요청 처리가 정상적으로 수행되는지 테스트하였습니다. BeforeEach에서는 각 테스트를 격리하여 수행하기 위해 데이터베이스의 값들을 초기화하고 테스트에 사용될 유저 객체의 정보와 MockMvc의 초기 설정을 수행해 주었습니다. 테스트 별 격리된 인증 정보 사용을 위해 테스트 수행전 MockMvc 객체에 존재하는 인증 정보를 초기화시켜 주었습니다.
@Slf4j
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
UserService userService;
@Autowired
UserRepository userRepository;
@Autowired
WebApplicationContext context;
User testUser;
@BeforeEach
void setUp() {
userRepository.deleteAll();
testUser = User.builder()
.loginId("testuser")
.password("password")
.username("Test User")
.email("test@example.com")
.build();
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.alwaysDo(result -> SecurityContextHolder.clearContext())
.alwaysDo(print())
.apply(springSecurity()) // addFilter 대신 apply 사용
.build();
}
먼저 회원 가입에 대한 테스트입니다. 회원 가입 폼 요청이 올바르게 수행되는지 그리고 회원 가입 처리가 정상적으로 수행되는지 테스트하는 부분입니다. 회원 가입 폼 요청의 경우 익명 사용자 요청으로 처리하기 위해 @WithAnonymousUser 어노테이션을 사용해 주었습니다. 회원 가입 처리 테스트의 경우 가입 처리가 완료될 경우 메인 페이지로 리다이렉트 되고 회원 가입된 유저가 파라미터로 넘겼던 정보로 가입 처리가 잘 되었는지 확인하였습니다.
@Test
@WithAnonymousUser
@DisplayName("회원 가입 폼 요청 테스트")
void testSignupForm() throws Exception {
mockMvc.perform(get("/user/signup"))
.andExpect(status().isOk())
.andExpect(handler().handlerType(UserFormController.class))
.andExpect(handler().methodName("signupForm"))
.andExpect(model().attributeExists("userRequest"));
}
@Test
@DisplayName("회원 가입 처리 테스트")
void testSignupUser() throws Exception {
// when
mockMvc.perform(post("/manage/user/signup")
.with(csrf())
.param("loginId", "testuser")
.param("password", "password123")
.param("username", "Test User")
.param("email", "test@example.com"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
// then
User savedUser = userRepository.findByLoginId("testuser").orElse(null);
assertThat(savedUser).isNotNull();
assertThat(savedUser.getUsername()).isEqualTo("Test User");
}
마이페이지에 대한 테스트입니다. 마이페이지 요청이 정상적으로 수행되는지 테스트하는 코드로 회원 가입을 수행한 사용자의 인증을 user(new CustomUserDetails(user)))로 넘겨 수행하였습니다. 현재 UserDetails를 확장한 CustomUserDetails를 Spring Security의 인증으로 사용하고 있기 때문에 @WithMockUser 어노테이션과의 불일치 문제로 인해 직접 인증 정보를 담아 요청을 테스트하였습니다. 사용자 정보 수정 테스트의 경우 수정 값이 올바르게 처리되었는지 테스트하였습니다.
@Test
@DisplayName("마이페이지 요청 테스트")
void testMyPage() throws Exception {
// given
User user = userService.signupUser(testUser);
// when & then
mockMvc.perform(get("/user/mypage")
.with(csrf())
.with(user(new CustomUserDetails(user))))
.andExpect(status().isOk())
.andExpect(handler().handlerType(UserFormController.class))
.andExpect(handler().methodName("myPage"))
.andExpect(model().attributeExists("user", "userRequest", "posts"))
.andReturn();
}
@Test
@DisplayName("사용자 정보 수정 테스트")
void testUpdateUser() throws Exception {
// given
User user = userService.signupUser(testUser);
// when
mockMvc.perform(post("/manage/user/mypage")
.with(csrf())
.with(user(new CustomUserDetails(user)))
.param("username", "Updated User")
.param("email", "updated@example.com")
.param("password", "updatedPassword"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/user/mypage"));
User updateUser = userService.updateUser(user.getId(), new UserUpdateDTO(
"Updated User",
"updated@example.com",
"updatedPassword")
);
// then
User findUser = userRepository.findByLoginId("testuser").orElse(null);
assertThat(findUser).isNotNull();
assertThat(findUser.getUsername()).isEqualTo("Updated User");
assertThat(findUser.getEmail()).isEqualTo("updated@example.com");
assertThat(findUser.getPassword()).isEqualTo(updateUser.getPassword());
}
}
Post
User에 비해 더 많은 수정과 추가가 이루어졌고 가장 많은 기능을 담당하다 보니 테스트의 코드 또한 많이 존재합니다.
변경 사항
- PostController 메서드 추가 및 로직 수정 -> 작성, 수정 취소 메서드 추가, 작성 수정 메서드 로직 변경
- PostFormController 로직 수정 -> 작성, 수정 메서드 로직 수정
- PostRepository 쿼리 수정 -> fetch join 메서드 left 조인 수정
추가 사항
- PostApiController 추가
- PostControllerTest 추가
변경 내용
class PostController - 수정
게시글 작성과 수정 로직이 보완되었습니다. 작성 및 수정에서 게시글 등록 요청 시 uploadPhoto인 이미지 업로드에서 오류가 발생할 경우 작성했던 게시글의 내용을 다시 가져와 재시도할 수 있게 처리하였습니다. 등록이 실패하는 경우 다시 게시글 작성 화면으로 리다이렉트 되어 이전 작성 내용을 유지한 채 다시 시도를 할 수 있습니다. 해당 로직에 대한 처리를 처음 코드에 추가하였으나 수정과 작성에 동일한 로직이 포함되어 aop를 사용해 코드를 분리하였습니다. 이미지의 경우 임시 파일에서 정식 파일로 성공적으로 변환이 된 파일은 삭제 처리 되고 임시 파일 상태인 이미지만 유지되게 됩니다. 그리고 리다이렉트 된 상태에서 작성 또는 수정을 취소할 경우 세션에 저장되어 있던 임시 데이터를 제거하기 위한 메서드가 추가되었습니다.
/**
* 작성된 게시글 내용 session 에 임시 저장
* 성공 시 session 에 임시 저장된 내용 삭제
* 실패 시 다시 작성 폼으로 redirect
*/
@PostMapping("/write")
@SavePostRequest
public String createPost(@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @ModelAttribute PostDTO.Request request,
HttpSession session) {
Long userId = userDetails != null ? userDetails.getId() : null;
Post createdPost = postService.createPost(postMapper.toEntity(request), userId);
photoService.uploadPhoto(createdPost.getId(), session.getId());
return "redirect:/post/" + createdPost.getId();
}
/**
* 수정된 게시글 내용 session 에 임시 저장
* 성공 시 session 에 임시 저장된 내용 삭제
* 실패 시 다시 수정 폼으로 redirect
*/
@PostMapping("/{id}/edit")
@SavePostRequest
public String updatePost(@Valid @ModelAttribute PostDTO.Request request,
@PathVariable("id") Long postId, HttpSession session) {
postService.updatePost(postId, postMapper.toUpdateDTO(request));
photoService.updatePhoto(postId, session.getId());
return "redirect:/post/" + postId;
}
@PostMapping("/post/write/cancel")
public String cancelCreatePost(HttpSession session) {
session.removeAttribute("postCreateRequest");
return "redirect:/";
}
@PostMapping("/post/{id}/edit/cancel")
public String cancelEditPost(HttpSession session) {
session.removeAttribute("postEditRequest");
return "redirect:/";
}
class PostFormController - 수정
작성 실패 시 이전 임시 작성 내용으로 다시 요청을 보내기 때문에 요청을 받았을 때 세션에 저장된 내용이 존재한다면 실패 후 재요청된 상태라 판단하고 임시 저장된 내용을 model에 담아 넘기게 수정하였습니다.
/**
* 게시글 작성 실패 시 다시 작성 폼으로 redirect
* session 에 저장된 작성 내용을 가져옴
* session 에 작성된 내용이 없는 경우 새로운 게시글 작성
*/
@GetMapping("post/write")
public String createPost(@AuthenticationPrincipal CustomUserDetails userDetails, Model model, HttpSession session) {
PostDTO.Request request = (PostDTO.Request) session.getAttribute("postCreateRequest");
if (request == null) {
request = new PostDTO.Request();
if (userDetails != null) {
request.setDisplayName(userDetails.getDisplayName());
}
}
model.addAttribute("request", request);
return "post/write";
}
/**
* 게시글 수정 실패 시 다시 수정 폼으로 redirect
* session 에 저장된 작성 내용을 가져옴
* session 에 작성된 내용이 없는 경우 새로운 게시글 수정 요청으로 취급
*/
@GetMapping("post/{id}/edit")
public String updatePost(@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("id") Long postId,
@RequestParam(required = false) String password,
Model model,
HttpSession session) {
Post post = postService.viewPost(postId);
// 회원 게시글인 경우
if (post.getUser() != null) {
if (checkPermission(userDetails, post)) {
throw new UnauthorizedAccessException("게시글에 대한 수정 권한이 없습니다.");
}
}
// 비회원 게시글인 경우
else {
if (checkPassword(postId, password)) {
throw new UnauthorizedAccessException("비밀번호가 일치하지 않습니다.");
}
}
PostDTO.Request request = (PostDTO.Request) session.getAttribute("postEditRequest");
if (request == null) {
request = postMapper.toRequestDTO(post);
}
model.addAttribute("request", request);
return "post/edit";
}
interface PostRepository - 수정
기존 post와 user를 같이 fetch join으로 가져오던 메서드를 left fetch join으로 user가 null인 상태에서도 정상적으로 동작하게 수정하였습니다.
@Query("SELECT p FROM Post p LEFT JOIN FETCH p.user WHERE p.id = :postId")
Optional<Post> findByIdWithUser(@Param("postId") Long postId);
추가 내용
class PostApiController - 추가
Post의 추천/비추천 로직 처리를 위한 RestController를 추가하였습니다. 비동기 처리를 위해 RestController를 사용해 처리하였습니다.
@RestController
@RequestMapping("/api/post")
@RequiredArgsConstructor
public class PostApiController {
private final PostService postService;
@PostMapping("/{id}/recommend")
public ResponseEntity<?> recommendPost(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("id") Long postId,
HttpSession session) {
try {
Long userId = userDetails != null ? userDetails.getId() : null;
postService.recommendPost(postId, userId, session.getId());
Post post = postService.viewPost(postId);
return ResponseEntity.ok(Map.of(
"message", "추천이 완료되었습니다.",
"recomCount", post.getRecomCount()
));
} catch (AlreadyRecommendedException | AlreadyNotRecommendedException e) {
return ResponseEntity.badRequest().body(Map.of(
"message", e.getMessage()
));
}
}
@PostMapping("/{id}/not-recommend")
public ResponseEntity<?> notRecommendPost(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("id") Long postId,
HttpSession session) {
try {
Long userId = userDetails != null ? userDetails.getId() : null;
postService.notRecommendPost(postId, userId, session.getId());
Post post = postService.viewPost(postId);
return ResponseEntity.ok(Map.of(
"message", "비추천이 완료되었습니다.",
"notRecomCount", post.getNotRecomCount()
));
} catch (AlreadyRecommendedException | AlreadyNotRecommendedException e) {
return ResponseEntity.badRequest().body(Map.of(
"message", e.getMessage()
));
}
}
}
class PostControllerTest - 추가
PostController에 대한 통합 테스트 부분입니다. 추천과 비추천 로직은 Embedded-Redis를 통해 테스트를 수행하였고 MockHttpSession을 사용해 세션이 필요한 부분도 테스트를 수행해 주었습니다.
@Slf4j
@SpringBootTest
@AutoConfigureMockMvc
@Import(EmbeddedRedisConfig.class)
class PostControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
PostService postService;
@Autowired
PhotoService photoService;
@MockitoBean
PhotoService mockPhotoService;
@Autowired
UserRepository userRepository;
@Autowired
PostRepository postRepository;
@Autowired
PhotoRepository photoRepository;
@Autowired
CommentService commentService;
@Autowired
RedisTemplate<String, String> redisTemplate;
@Autowired
WebApplicationContext context;
User testUser;
Post testPost;
MockMultipartFile testImage;
static final String UPLOAD_PATH = "D:\\project\\images";
static final String TEST_SESSION_ID = "test-session-id";
@BeforeEach
void setUp() {
File directory = new File(UPLOAD_PATH);
if (!directory.exists()) {
directory.mkdirs();
}
postRepository.deleteAll();
userRepository.deleteAll();
RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
connection.serverCommands().flushDb();
connection.close();
testUser = userRepository.save(User.builder()
.loginId("testuser")
.password("password")
.username("Test User")
.email("test@example.com")
.build());
testImage = new MockMultipartFile(
"file",
"test-image.jpg",
MediaType.IMAGE_JPEG_VALUE,
"test image content".getBytes()
);
MockHttpSession session = new MockHttpSession();
session.setAttribute("sessionId", TEST_SESSION_ID);
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.alwaysDo(result -> SecurityContextHolder.clearContext())
.alwaysDo(print())
.apply(springSecurity())
.build();
}
회원/비회원 게시글 작성 테스트입니다. 성공 시나리오로 작성이 완료된 후 게시글 작성 여부, 회원 작성 시 회원과 연동 여부, 작성 후 작성된 게시글로 리다이렉트 되는 부분을 테스트하였습니다.
@Test
@DisplayName("회원 게시글 작성 테스트")
void testCreatePostWithUser() throws Exception {
// given
// 이미지 임시 업로드
String imagePath = photoService.tempUploadPhoto(testImage, TEST_SESSION_ID);
// when
mockMvc.perform(post("/manage/post/write")
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("title", "Test Title")
.param("content", "Test Content with " + imagePath)
.param("type", "NORMAL")
.param("displayName", "Test User"))
.andExpect(status().is3xxRedirection());
Post savedPost = postRepository.findAll().get(0);
// then
assertThat(savedPost.getTitle()).isEqualTo("Test Title");
assertThat(savedPost.getUser()).isNotNull();
assertThat(redirectedUrlPattern("/post/" + savedPost.getId())).isNotNull();
}
@Test
@DisplayName("비회원 게시글 작성 테스트")
void testCreatePostWithoutUser() throws Exception {
// given
// 이미지 임시 업로드
String imagePath = photoService.tempUploadPhoto(testImage, TEST_SESSION_ID);
// when
mockMvc.perform(post("/manage/post/write")
.with(csrf())
.param("title", "Anonymous Post")
.param("content", "Anonymous Content " + imagePath)
.param("type", "NORMAL")
.param("displayName", "Anonymous")
.param("password", "password123"))
.andExpect(status().is3xxRedirection());
Post savedPost = postRepository.findAll().get(0);
// then
assertThat(savedPost.getTitle()).isEqualTo("Anonymous Post");
assertThat(savedPost.getUser()).isNull();
assertThat(redirectedUrlPattern("/post/" + savedPost.getId())).isNotNull();
}
게시글 수정 테스트입니다. 회원과 비회원의 게시글 수정 테스트로 게시글 내용 수정이 잘 수행되었는지 테스트하였습니다.
@Test
@DisplayName("회원 게시글 수정 테스트")
void testUpdatePostWithUser() throws Exception {
// given
Post savedPost = postService.createPost(Post.builder()
.title("Original Title")
.content("Original Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
// when
mockMvc.perform(post("/manage/post/{id}/edit", savedPost.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("title", "Updated Title")
.param("content", "Updated Content")
.param("type", "NORMAL"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + savedPost.getId()));
Post updatedPost = postService.viewPost(savedPost.getId());
// then
assertThat(updatedPost.getTitle()).isEqualTo("Updated Title");
}
@Test
@DisplayName("비회원 게시글 수정 테스트")
void testUpdatePostWithoutUser() throws Exception {
// given
Post savedPost = postService.createPost(Post.builder()
.title("Anonymous Title")
.displayName("AnonymousUser")
.password("password123")
.content("Anonymous Content")
.postType(PostType.NORMAL)
.build(), null);
// when
mockMvc.perform(post("/manage/post/{id}/edit", savedPost.getId())
.with(csrf())
.param("title", "Updated Title")
.param("content", "Updated Content")
.param("type", "NORMAL")
.param("password", "password123"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + savedPost.getId()));
Post updatedPost = postService.viewPost(savedPost.getId());
// then
assertThat(updatedPost.getTitle()).isEqualTo("Updated Title");
}
회원, 비회원 게시글 수정 폼 요청 테스트입니다. 수정을 위한 폼 요청 시 권한 검증을 수행하기 때문에 회원의 경우 인증 정보로 넘긴 회원 정보와 비교하여 권한을 검증하고 비회원의 경우 게시글에 입력된 비밀번호와 일치 여부를 기준으로 권한을 검증하게 됩니다.
@Test
@DisplayName("회원 게시글 수정 폼 요청 테스트")
void testUpdatePostFormWithUser() throws Exception {
// given
Post savedPost = postService.createPost(Post.builder()
.title("Original Title")
.content("Original Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
// when & then
mockMvc.perform(get("/post/{id}/edit", savedPost.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser))))
.andExpect(status().isOk())
.andExpect(model().attributeExists("request"));
}
@Test
@DisplayName("비회원 게시글 수정 폼 요청 테스트")
void testUpdatePostFormWithoutUser() throws Exception {
// given
Post savedPost = postService.createPost(Post.builder()
.title("Anonymous Title")
.displayName("AnonymousUser")
.password("password123")
.content("Anonymous Content")
.postType(PostType.NORMAL)
.build(), null);
// when & then
mockMvc.perform(get("/post/{id}/edit", savedPost.getId())
.with(csrf())
.param("password", "password123"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("request"));
}
게시글 삭제에 대한 테스트입니다. 게시글 삭제 수행 시 연관된 댓글, 이미지 정보가 데이터베이스에서 같이 정상적으로 삭제되는지 여부와 이미지의 경우 로컬 저장소에 존재하는 실제 파일까지 같이 제거가 수행되는지 여부를 테스트하였습니다.
@Test
@DisplayName("게시글 삭제 테스트 - 연관 데이터(댓글, 이미지) 삭제 확인")
void testDeletePost() throws Exception {
// given
// 테스트용 이미지 파일 생성
MockMultipartFile testImage = new MockMultipartFile(
"file",
"test-image.jpg",
MediaType.IMAGE_JPEG_VALUE,
"test image content".getBytes()
);
// 임시 이미지 업로드
String imagePath = photoService.tempUploadPhoto(testImage, "test-session");
// 이미지가 포함된 게시글 생성
Post savedPost = postService.createPost(Post.builder()
.title("Test Title")
.content("Test Content with " + imagePath)
.postType(PostType.NORMAL)
.build(), testUser.getId());
// 이미지 정식 등록
List<Photo> savedPhotos = photoService.uploadPhoto(savedPost.getId(), "test-session");
Photo savedPhoto = savedPhotos.get(0);
// 회원 댓글 생성
Comment memberComment = Comment.builder()
.content("Member Comment")
.displayName("Test User")
.build();
Comment savedMemberComment = commentService.createComt(memberComment, savedPost.getId(), testUser.getId());
// 비회원 댓글 생성
Comment nonMemberComment = Comment.builder()
.content("Non-Member Comment")
.displayName("Anonymous")
.password("password123")
.build();
Comment savedNonMemberComment = commentService.createComt(nonMemberComment, savedPost.getId(), null);
// when
// 게시글 삭제 요청
mockMvc.perform(post("/manage/post/{id}/delete", savedPost.getId())
.with(user(new CustomUserDetails(testUser)))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/"));
// then
// 게시글 삭제 확인
assertThatThrownBy(() -> postService.viewPost(savedPost.getId()))
.isInstanceOf(PostNotFoundException.class);
// 댓글 삭제 확인
assertThatThrownBy(() -> commentService.viewComt(savedMemberComment.getId()))
.isInstanceOf(CommentNotFoundException.class);
assertThatThrownBy(() -> commentService.viewComt(savedNonMemberComment.getId()))
.isInstanceOf(CommentNotFoundException.class);
// 이미지 파일 삭제 확인
File imageFile = new File(UPLOAD_PATH + savedPhoto.getSavedPhotoName());
assertThat(imageFile.exists()).isFalse();
// 이미지 DB 데이터 삭제 확인
assertThat(photoRepository.findById(savedPhoto.getId())).isEmpty();
}
게시글 추천과 관련된 테스트입니다. 게시글 추천이 성공적으로 이루어지는지 요청 수행 테스트와 실패 시나리오를 테스트하였습니다. 추천/비추천 로직의 경우 회원이라면 24시간 내에 한 게시글에 추천 혹은 비추천 둘 중 하나만 수행할 수 있으며 비회원의 경우 세션 id 정보를 기준으로 처리됩니다. 중복 추천에 대한 실패 시나리오와 한 게시글에 대한 추천과 비추천을 모두 할 경우 실패하는 시나리오를 기준으로 테스트하였습니다.
@Test
@DisplayName("게시글 추천 테스트")
void testRecommendPost() throws Exception {
// given
Post savedPost = postService.createPost(Post.builder()
.title("Test Title")
.content("Test Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
// when
mockMvc.perform(post("/api/post/{id}/recommend", savedPost.getId())
.with(user(new CustomUserDetails(testUser)))
.with(csrf()))
.andExpect(status().isOk());
// then
Post recommendedPost = postService.viewPost(savedPost.getId());
assertThat(recommendedPost.getRecomCount()).isEqualTo(1);
}
@Test
@DisplayName("이미 추천한 게시글 재추천 시 실패 테스트")
void testRecommendAlreadyRecommendedPost() throws Exception {
// given
// 테스트용 게시글 생성
Post savedPost = postService.createPost(Post.builder()
.title("Test Title")
.content("Test Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
CustomUserDetails user = new CustomUserDetails(testUser);
// when
// 첫 번째 추천 요청
mockMvc.perform(post("/api/post/{id}/recommend", savedPost.getId())
.with(user(user))
.with(csrf()))
.andExpect(status().isOk());
// 동일 게시글 재추천 시도
mockMvc.perform(post("/api/post/{id}/recommend", savedPost.getId())
.with(user(user))
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("이미 추천한 게시글입니다."));
// then
// 추천 수가 1로 유지되는지 확인
Post post = postService.viewPost(savedPost.getId());
assertThat(post.getRecomCount()).isEqualTo(1);
}
@Test
@DisplayName("비추천한 게시글 추천 시 실패 테스트")
void testRecommendAfterNotRecommend() throws Exception {
// given
// 테스트용 게시글 생성
Post savedPost = postService.createPost(Post.builder()
.title("Test Title")
.content("Test Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
CustomUserDetails user = new CustomUserDetails(testUser);
// when
// 비추천 요청
mockMvc.perform(post("/api/post/{id}/not-recommend", savedPost.getId())
.with(user(user))
.with(csrf()))
.andExpect(status().isOk());
// 동일 게시글 추천 시도
mockMvc.perform(post("/api/post/{id}/recommend", savedPost.getId())
.with(user(user))
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("이미 비추천한 게시글입니다."));
// then
// 비추천 수는 1이고 추천 수는 0인지 확인
Post post = postService.viewPost(savedPost.getId());
assertThat(post.getNotRecomCount()).isEqualTo(1);
assertThat(post.getRecomCount()).isEqualTo(0);
}
게시글 검색 테스트입니다. 검색이 가능한 항목을 통해 검색 쿼리가 정상적으로 수행되는지 테스트하였습니다.
@Test
@DisplayName("게시글 검색 테스트")
void testSearchPosts() throws Exception {
// given
// 테스트용 게시글 생성
Post titlePost = postService.createPost(Post.builder()
.title("Search Test Title")
.displayName("Test User")
.content("Normal Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
Post contentPost = postService.createPost(Post.builder()
.title("Normal Title")
.displayName("Test User")
.content("Search Test Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
Post bothPost = postService.createPost(Post.builder()
.title("Search Test Both")
.displayName("Test User")
.content("Search Test Both Content")
.postType(PostType.NORMAL)
.build(), testUser.getId());
// when
// 제목으로 검색
MvcResult titleResult = mockMvc.perform(get("/posts/search")
.param("searchType", "TITLE")
.param("keyword", "Search"))
.andExpect(status().isOk())
.andExpect(view().name("posts/list :: #postsFragment"))
.andExpect(model().attributeExists("posts"))
.andReturn();
// then
List<PostDTO.ListResponse> titleSearchResults = (List<PostDTO.ListResponse>) titleResult
.getModelAndView().getModel().get("posts");
assertThat(titleSearchResults).hasSize(2)
.extracting("title")
.contains("Search Test Title", "Search Test Both");
// when
// 내용으로 검색
MvcResult contentResult = mockMvc.perform(get("/posts/search")
.param("searchType", "CONTENT")
.param("keyword", "Search"))
.andExpect(status().isOk())
.andReturn();
// then
List<PostDTO.ListResponse> contentSearchResults = (List<PostDTO.ListResponse>) contentResult
.getModelAndView().getModel().get("posts");
assertThat(contentSearchResults).hasSize(2)
.extracting("title")
.contains("Normal Title", "Search Test Both");
// when
// 제목+내용으로 검색
MvcResult bothResult = mockMvc.perform(get("/posts/search")
.param("searchType", "TITLE_CONTENT")
.param("keyword", "Search"))
.andExpect(status().isOk())
.andReturn();
// then
List<PostDTO.ListResponse> bothSearchResults = (List<PostDTO.ListResponse>) bothResult
.getModelAndView().getModel().get("posts");
assertThat(bothSearchResults).hasSize(3)
.extracting("title")
.contains("Search Test Title", "Normal Title", "Search Test Both");
// when
// 작성자로 검색
MvcResult authorResult = mockMvc.perform(get("/posts/search")
.param("searchType", "AUTHOR")
.param("keyword", "Test User"))
.andExpect(status().isOk())
.andReturn();
// then
List<PostDTO.ListResponse> authorSearchResults = (List<PostDTO.ListResponse>) authorResult
.getModelAndView().getModel().get("posts");
assertThat(authorSearchResults).hasSize(3)
.extracting("username")
.containsOnly("Test User");
}
게시글 작성, 수정 시 업로드 실패 테스트를 수행했습니다. 업로드가 실패한 경우 글로벌 핸들러가 정상적으로 FileUploadException에 대한 처리를 수행하는지 테스트하였습니다.
@Test
@DisplayName("게시글 작성 시 파일 업로드 실패 테스트")
void testCreatePostWithFileUploadFailure() throws Exception {
// Given
PostDTO.Request request = new PostDTO.Request(
"Test Title"
, "Test Content"
, "NORMAL"
, "Test User"
, null
);
// When & Then
doThrow(new FileUploadException("파일 업로드에 실패했습니다."))
.when(mockPhotoService).uploadPhoto(any(Long.class), any(String.class));
mockMvc.perform(post("/manage/post/write")
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.flashAttr("request", request))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/write"))
.andExpect(flash().attributeExists("request"))
.andExpect(flash().attribute("errorMessage", "파일 업로드에 실패했습니다."));
}
@Test
@DisplayName("게시글 수정 시 파일 업로드 실패 테스트")
void testUpdatePostWithFileUploadFailure() throws Exception {
// Given
Post savedPost = postRepository.save(Post.builder()
.title("Original Title")
.content("Original Content")
.postType(PostType.NORMAL)
.user(testUser)
.build());
PostDTO.Request request = new PostDTO.Request();
request.setTitle("Updated Title");
request.setContent("Updated Content");
request.setDisplayName("Test User");
// When & Then
doThrow(new FileUploadException("파일 업로드에 실패했습니다."))
.when(photoService).updatePhoto(any(Long.class), any(String.class));
mockMvc.perform(post("/manage/post/" + savedPost.getId() + "/edit")
.header("Referer", "/post/" + savedPost.getId() + "/edit")
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.flashAttr("request", request))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + savedPost.getId() + "/edit"))
.andExpect(flash().attributeExists("request"))
.andExpect(flash().attribute("errorMessage", "파일 업로드에 실패했습니다."));
}
테스트 수행이 끝난 뒤 로컬 파일 정리를 위한 코드입니다.
@AfterEach
void tearDown() {
RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
connection.serverCommands().flushDb();
connection.close();
// 업로드된 파일 정리
File directory = new File("D:\\project\\images");
if (directory.exists()) {
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
file.delete();
}
}
}
}
Comment
Comment의 경우 테스트 개발과 Controller에서 댓글 수정을 요청하는 경우 수정 폼을 얻기 전에 권한 검증을 하는 것이 아니라 수정 로직 요청 시 권한 검증을 수행하는 잘못된 방식으로 구현되어 있던 부분을 수정하였습니다.
변경 사항
- CommentController의 댓글 수정 요청의 권한 검증을 CommentFormController 댓글 수정 폼 요청에서 실시하게 변경
- CommentRepository 수정 -> fetch join을 left fetch join으로 수정
추가 사항
- CommentControllerTest 추가
변경 내용
class CommentController - 수정
기존에 수정 로직에 존재하던 권한 검증을 제거하고 CommentFormController에서 수행하게 처리하였습니다.
@PostMapping("/{commentId}/edit")
public String updateComment(@Valid @ModelAttribute CommentDTO.Request commentEditRequest,
@PathVariable("commentId") Long commentId,
@PathVariable("postId") Long postId) {
commentService.updateComt(commentId, commentMapper.toUpdateDTO(commentEditRequest));
return "redirect:/post/" + postId;
}
class CommentFormController- 수정
@GetMapping("/comment/{commentId}/edit")
public String getCommentEditForm(@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("postId") Long postId,
@PathVariable("commentId") Long commentId,
@RequestParam(required = false) String password,
Model model) {
Comment comment = commentService.viewComtWithUser(commentId);
// 회원 댓글인 경우
if (comment.getUser() != null) {
if (checkPermission(userDetails, comment)) {
throw new UnauthorizedAccessException("댓글에 대한 수정 권한이 없습니다.");
}
}
// 비회원 댓글인 경우
else {
if (checkPassword(commentId, password)) {
throw new UnauthorizedAccessException("비밀번호가 일치하지 않습니다.");
}
}
model.addAttribute("commentEditRequest", commentMapper.toRequestDTO(comment));
return "post/view :: #commentEditForm";
}
interface CommentRepository - 수정
left 조인으로 다른 객체들이 null인 경우에도 정상 동작하게 수정하였습니다.
@Query("SELECT c FROM Comment c LEFT JOIN FETCH c.user WHERE c.id = :commentId")
Optional<Comment> findByIdWithUser(@Param("commentId") Long commentId);
@Query("SELECT c FROM Comment c LEFT JOIN FETCH c.parentComment WHERE c.id = :commentId")
Optional<Comment> findByIdWithParent(@Param("commentId") Long commentId);
@Query("SELECT c FROM Comment c " +
"LEFT JOIN FETCH c.user " +
"LEFT JOIN FETCH c.parentComment " +
"WHERE c.id = :commentId")
Optional<Comment> findByIdWithUserAndParent(@Param("commentId") Long commentId);
추가 내용
class CommentControllerTest - 추가
Comment에 대한 통합 테스트 코드입니다.
@Slf4j
@SpringBootTest
@AutoConfigureMockMvc
class CommentControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
UserRepository userRepository;
@Autowired
PostRepository postRepository;
@Autowired
CommentService commentService;
@Autowired
CommentRepository commentRepository;
@Autowired
WebApplicationContext context;
User testUser;
Post testPost;
Comment parentComment;
Comment childComment;
@BeforeEach
void setUp() {
SecurityContextHolder.clearContext();
commentRepository.deleteAll();
postRepository.deleteAll();
userRepository.deleteAll();
testUser = userRepository.save(User.builder()
.loginId("testuser")
.password("password")
.username("Test User")
.email("test@example.com")
.build());
testPost = postRepository.save(Post.builder()
.title("Test Post")
.content("Test Content")
.postType(PostType.NORMAL)
.user(testUser)
.build());
// 부모 댓글 생성
parentComment = Comment.builder()
.content("Parent Comment")
.displayName("Parent User")
.build();
// 자식 댓글 생성
childComment = Comment.builder()
.content("Child Comment")
.displayName("Child User")
.build();
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.alwaysDo(result -> SecurityContextHolder.clearContext())
.alwaysDo(print())
.apply(springSecurity())
.build();
}
@AfterEach
void tearDown() {
commentRepository.deleteAll();
postRepository.deleteAll();
userRepository.deleteAll();
}
회원에 대한 원본 댓글과 대댓글 작성 테스트입니다. 댓글 작성 로직이 정상 수행되는지 여부와 대댓글 작성이 정상적으로 수행되는지 테스트하는 내용입니다.
@Test
@DisplayName("회원 원본 댓글 작성 테스트")
void testCreateOriginalCommentWithUser() throws Exception {
// when
// 댓글 작성 테스트 수행
mockMvc.perform(post("/manage/post/{postId}/comment/write", testPost.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("content", "Original Comment")
.param("displayName", "Test User"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// then
List<Comment> comments = commentRepository.findAll();
Comment comment = comments.get(0);
assertThat(comments.size()).isEqualTo(1);
assertThat(comment.getUser()).isNotNull();
}
@Test
@DisplayName("회원 대댓글 작성 테스트")
void testCreateReplyCommentWithUser() throws Exception {
// when
// 부모 댓글 저장
Comment savedParent = commentService.createComt(parentComment, testPost.getId(), testUser.getId());
mockMvc.perform(post("/manage/post/{postId}/comment/write", testPost.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("content", "Reply Comment")
.param("displayName", "Test User")
.param("parentId", savedParent.getId().toString()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// then
// 대댓글 저장 확인
List<Comment> childComments = commentService.viewChildComts(savedParent.getId());
assertThat(childComments).hasSize(1);
assertThat(childComments.get(0).getParentComment().getId()).isEqualTo(savedParent.getId());
}
비회원에 대한 로직입니다. 회원의 정보 대신 비밀번호를 파라미터로 넘기게 됩니다.
@Test
@DisplayName("비회원 댓글 작성 테스트")
void testCreateCommentWithoutUser() throws Exception {
// when
mockMvc.perform(post("/manage/post/{postId}/comment/write", testPost.getId())
.with(csrf())
.param("content", "Anonymous Comment")
.param("displayName", "Anonymous")
.param("password", "password123"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// then
// 댓글 저장 확인
Page<Comment> comments = commentService.viewComts(testPost.getId(), PageRequest.of(0, 10));
Comment savedComment = comments.getContent().get(0);
assertThat(savedComment.getContent()).isEqualTo("Anonymous Comment");
assertThat(savedComment.getUser()).isNull();
assertThat(savedComment.getPassword()).isEqualTo("password123");
}
@Test
@DisplayName("비회원 대댓글 작성 테스트")
void testCreateReplyCommentWithoutUser() throws Exception {
// when
// 부모 댓글 저장
Comment savedParent = commentService.createComt(parentComment, testPost.getId(), testUser.getId());
mockMvc.perform(post("/manage/post/{postId}/comment/write", testPost.getId())
.with(csrf())
.param("content", "Reply Anonymous Comment")
.param("displayName", "Anonymous")
.param("password", "password123")
.param("parentId", savedParent.getId().toString()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// then
// 대댓글 저장 확인
List<Comment> childComments = commentService.viewChildComts(savedParent.getId());
assertThat(childComments).hasSize(1);
assertThat(childComments.get(0).getParentComment().getId()).isEqualTo(savedParent.getId());
}
댓글 수정 테스트입니다.
@Test
@DisplayName("댓글 수정 테스트")
void testUpdateComment() throws Exception {
Comment savedComment = commentService.createComt(childComment, testPost.getId(), testUser.getId());
// when
mockMvc.perform(post("/manage/post/{postId}/comment/{commentId}/edit", testPost.getId(), savedComment.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("content", "Updated Comment")
.param("displayName", "Test User"))
.andDo(MockMvcResultHandlers.print())
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
Comment updatedComment = commentService.viewComt(savedComment.getId());
// then
assertThat(updatedComment.getContent()).isEqualTo("Updated Comment");
}
댓글 삭제에 대한 테스트입니다. 대댓글이 있는 경우와 없는 경우를 나눠 진행하였습니다. 대댓글이 없는 경우 데이터베이스 내에서 완전 삭제 처리가 되는지를 중점으로 수행하고 대댓글이 있는 경우 isDeleted 필드의 상태 변경이 올바르게 이루어지는지를 검증하였습니다.
@Test
@DisplayName("대댓글이 없는 댓글 삭제 테스트")
void testDeleteCommentWithoutReplies() throws Exception {
// given
Comment savedComment = commentService.createComt(parentComment, testPost.getId(), testUser.getId());
// when
mockMvc.perform(post("/manage/post/{postId}/comment/{commentId}/delete",
testPost.getId(), savedComment.getId())
.with(user(new CustomUserDetails(testUser)))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andDo(MockMvcResultHandlers.print())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// then
assertThatThrownBy(() -> commentService.viewComt(savedComment.getId())).isInstanceOf(CommentNotFoundException.class);
}
@Test
@DisplayName("대댓글이 있는 댓글 삭제 테스트")
void testDeleteCommentWithReplies() throws Exception {
// given
// 부모 댓글 저장
Comment savedParent = commentService.createComt(parentComment, testPost.getId(), testUser.getId());
// 자식 댓글 저장
childComment.connectParent(savedParent);
Comment savedChild = commentService.createComt(childComment, testPost.getId(), testUser.getId());
// when
mockMvc.perform(post("/manage/post/{postId}/comment/{commentId}/delete",
testPost.getId(), savedParent.getId())
.with(user(new CustomUserDetails(testUser)))
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andDo(MockMvcResultHandlers.print())
.andExpect(redirectedUrl("/post/" + testPost.getId()));
// 상태 변경 확인
Comment deletedParent = commentService.viewComt(savedParent.getId());
Comment deletedChild = commentService.viewComt(savedChild.getId());
assertThat(deletedParent.isDeleted()).isTrue();
assertThat(deletedChild.isDeleted()).isTrue();
}
댓글 페이지 요청 테스트입니다.
@Test
@DisplayName("댓글 페이지 조회 테스트")
void testGetCommentsByPage() throws Exception {
// given
commentService.createComt(childComment, testPost.getId(), testUser.getId());
// when & then
mockMvc.perform(get("/post/{postId}/comments/page", testPost.getId())
.param("pageGroup", "0"))
.andExpect(status().isOk())
.andExpect(view().name("post/view :: #commentsFragment"))
.andExpect(model().attributeExists("comments", "commentRequest"));
}
회원과 비회원의 댓글 수정 폼 요청 테스트입니다. 시나리오 별 권한 인증을 검증하는 테스트입니다.
@Test
@DisplayName("회원 댓글 수정 폼 조회 테스트")
void testGetCommentEditFormWithUser() throws Exception {
// given
Comment savedComment = commentService.createComt(parentComment, testPost.getId(), testUser.getId());
// when & then
mockMvc.perform(get("/post/{postId}/comment/{commentId}/edit",
testPost.getId(), savedComment.getId())
.with(user(new CustomUserDetails(testUser))))
.andExpect(status().isOk())
.andExpect(view().name("post/view :: #commentEditForm"))
.andExpect(model().attributeExists("commentEditRequest"));
}
@Test
@DisplayName("비회원 댓글 수정 폼 조회 테스트")
void testGetCommentEditFormWithoutUser() throws Exception {
// given
Comment nonUserComment = Comment.builder()
.content("Non-User Comment")
.displayName("Anonymous")
.password("password123")
.build();
Comment savedComment = commentService.createComt(nonUserComment, testPost.getId(), null);
// when & then
mockMvc.perform(get("/post/{postId}/comment/{commentId}/edit",
testPost.getId(), savedComment.getId())
.param("password", "password123"))
.andExpect(status().isOk())
.andExpect(view().name("post/view :: #commentEditForm"))
.andExpect(model().attributeExists("commentEditRequest"));
}
댓글 수정과 삭제 권한이 없는 사용자가 요청할 경우 예외 발생 테스트입니다. 글로벌 핸들러의 권한 인증 예외 검증을 위한 테스트입니다.
@Test
@DisplayName("권한 없는 회원의 댓글 수정 시도 테스트")
void testUpdateCommentWithoutPermission() throws Exception {
// Given
// 다른 사용자의 댓글 생성
User otherUser = userRepository.save(User.builder()
.loginId("otheruser")
.password("password")
.username("Other User")
.email("other@example.com")
.build());
Comment otherUserComment = commentService.createComt(parentComment, testPost.getId(), otherUser.getId());
// When & Then
mockMvc.perform(get("/post/{postId}/comment/{commentId}/edit",
testPost.getId(), otherUserComment.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser)))
.param("content", "Updated Comment")
.param("displayName", "Test User"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").value("댓글에 대한 수정 권한이 없습니다."));
}
@Test
@DisplayName("권한 없는 회원의 댓글 삭제 시도 테스트")
void testDeleteCommentWithoutPermission() throws Exception {
// Given
// 다른 사용자의 댓글 생성
User otherUser = userRepository.save(User.builder()
.loginId("otheruser")
.password("password")
.username("Other User")
.email("other@example.com")
.build());
Comment otherUserComment = commentService.createComt(parentComment, testPost.getId(), otherUser.getId());
// When & Then
mockMvc.perform(post("/manage/post/{postId}/comment/{commentId}/delete",
testPost.getId(), otherUserComment.getId())
.with(csrf())
.with(user(new CustomUserDetails(testUser))))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").value("댓글에 대한 삭제 권한이 없습니다."));
}
Photo
Photo의 경우 실질적인 로직이 Post 내부에 존재해 임시 저장을 수행하는 tempUpload를 테스트하는 로직과 게시글 작성, 수정 실패 시 로직 변경으로 인한 약간의 수정이 존재합니다.
변경 사항
- PhotoController 이름 변경 및 제한 사항 추가 -> PhotoApiController
- PhotoService 로직 수정 -> 실패 시 정상 저장되었던 파일 삭제
추가 사항
- PhotoControllerTest 추가
변경 내용
class PhotoApiController - 수정
Controller의 이름이 ApiController로 수정이 되고 업로드 가능한 파일의 크기를 최대 4MB로 제한하였습니다.
@RestController
@RequestMapping("/api/photos")
@RequiredArgsConstructor
public class PhotoApiController {
private final PhotoService photoService;
@PostMapping("/temp-upload")
public ResponseEntity<String> tempUpload(@RequestParam("file") MultipartFile file,
HttpSession session) {
// 파일 크기 검증 (4MB)
if (file.getSize() > 4 * 1024 * 1024L) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body("File size exceeds maximum limit of 4MB");
}
try {
String imagePath = photoService.tempUploadPhoto(file, session.getId());
return ResponseEntity.ok(imagePath);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("Invalid file format");
} catch (FileUploadException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("File upload failed");
}
}
}
class PhotoService - 수정
Post 작성, 수정 실패 시 임시 저장 내용을 가지고 다시 작성화면으로 리다이렉트 처리 되기 때문에 리다이렉트 시 본문 내용에는 임시 파일 경로가 들어있는 상태로 다시 넘어가게 됩니다. 그럴 경우 예외 발생 전 처리된 이미지의 경우 정식 파일명으로 변경되어 저장된 상태로 본문의 임시 파일 경로와 매칭이 불가능하여 유령 파일이 되게 됩니다. 해당 유령 파일을 제거하는 로직을 추가하여 예외 발생 시 정식 파일로 변경된 파일을 로컬 저장소에서 제거하는 로직을 수행하게 됩니다. 임시 작성 내용을 가진 채 작성 화면으로 리다이렉트 될 경우 정식 파일로 변경되었던 파일에 대해선 사용자가 재업로드를 수행하여야 합니다. 코드가 길어 catch 문 아래의 부분만 첨부하였습니다. upload와 update 둘 다 적용되었습니다.
catch (Exception e) {
// 실패 시 이미 변경된 파일들 삭제
for (String path : pathMap.values()) {
String fileName = path.substring(path.lastIndexOf('/') + 1);
File file = new File(UPLOAD_PATH + fileName);
if (file.exists()) {
file.delete();
}
}
throw new FileUploadException("Failed to process file: " + tempFileName);
추가 내용
class PhotoControllerTest - 추가
tempUpload에 대한 검증이 수행되었습니다. 성공 시나리오와 함께 여러 실패 케이스가 같이 검증되었습니다.
@Slf4j
@SpringBootTest
@AutoConfigureMockMvc
class PhotoControllerTest {
@Autowired
MockMvc mockMvc;
MockHttpSession httpSession;
static final String TEST_SESSION_ID = "test-session-id";
static final String TEST_FILE_NAME = "test-image.jpg";
static final String TEMP_FILE_PREFIX = "temp_";
@BeforeEach
void setUp() {
httpSession = new MockHttpSession();
httpSession.setAttribute("sessionId", TEST_SESSION_ID);
}
@Test
@DisplayName("임시 이미지 업로드 테스트")
void testTempUpload() throws Exception {
// given
// Mock 파일 생성
MockMultipartFile file = new MockMultipartFile(
"file",
TEST_FILE_NAME,
MediaType.IMAGE_JPEG_VALUE,
"test image content".getBytes()
);
// when & then
// 요청 수행
mockMvc.perform(multipart("/api/photos/temp-upload")
.file(file)
.with(csrf())
.session(httpSession)
.contentType(MediaType.MULTIPART_FORM_DATA))
.andExpect(status().isOk())
.andExpect(content().string(containsString("/images/" + TEMP_FILE_PREFIX)))
.andExpect(content().string(containsString(".jpg")));
}
@Test
@DisplayName("파일이 없는 경우 업로드 실패 테스트")
void testTempUploadWithoutFile() throws Exception {
// when & then
mockMvc.perform(multipart("/api/photos/temp-upload")
.with(csrf())
.session(httpSession))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("잘못된 파일 확장자 업로드 테스트")
void testTempUploadWithInvalidExtension() throws Exception {
// given
MockMultipartFile file = new MockMultipartFile(
"file",
"test-file", // 확장자 없음
MediaType.APPLICATION_OCTET_STREAM_VALUE,
"test content".getBytes()
);
// when & then
mockMvc.perform(multipart("/api/photos/temp-upload")
.file(file)
.with(csrf())
.session(httpSession))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("큰 파일 업로드 테스트")
void testTempUploadWithLargeFile() throws Exception {
// given
// 4MB를 초과하는 파일 생성
byte[] content = new byte[4 * 1024 * 1024 + 1];
new Random().nextBytes(content);
MockMultipartFile file = new MockMultipartFile(
"file",
TEST_FILE_NAME,
MediaType.IMAGE_JPEG_VALUE,
content
);
// when & then
mockMvc.perform(multipart("/api/photos/temp-upload")
.file(file)
.with(csrf())
.session(httpSession))
.andExpect(status().isPayloadTooLarge());
}
@AfterEach
void tearDown() {
// 테스트 후 임시 파일 정리
File directory = new File("D:\\project\\images\\");
File[] tempFiles = directory.listFiles((dir, name) ->
name.startsWith(TEMP_FILE_PREFIX) && name.endsWith(".jpg"));
if (tempFiles != null) {
for (File file : tempFiles) {
file.delete();
}
}
}
}
Global
각 도메인 패키지가 아닌 global 패키지에 추가된 설정과 같은 파일들입니다. 메인 패키지에서 사용되는 로직 수정과 테스트 패키지 아래의 설정을 위한 클래스들이 추가되었습니다.
변경 사항
- GlobalExceptionHandler 수정 -> 예외 처리 추가
- LoginSuccessHandler 수정 -> 로그인 성공 시 리다이렉트 경로 수정
- WebSecurityConfig 수정 -> 허용 URL 추가
추가 사항
- aop 패키지 및 클래스 추가
- Embedded Redis 설정 클래스 추가 -> 테스트 패키지
- 테스트 용 application.properties 격리
변경 내용
class GlobalExceptionHandler - 수정
NotFound, FileUpload 등 추가적인 예외에 대해서도 처리하는 메서드를 추가하였습니다. 기존 인증 실패에 대한 메서드는 Http 상태 코드를 반환하여 비동기 처리를 수행하게 변경하였습니다.
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedAccessException.class)
public ResponseEntity<Map<String, String>> handleUnauthorizedAccess(UnauthorizedAccessException e) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(Map.of("message", e.getMessage()));
}
@ExceptionHandler({PostNotFoundException.class, UserNotFoundException.class, CommentNotFoundException.class})
public String handleNotFoundException(RuntimeException e, Model model) {
model.addAttribute("errorMessage", e.getMessage());
return "error/not-found";
}
@ExceptionHandler(FileUploadException.class)
public String handleFileUpload(FileUploadException e, Model model,
HttpServletRequest request,
RedirectAttributes redirectAttributes) {
String referer = request.getHeader("Referer");
// 세션에서 작성 내용 가져오기 - 수정 페이지
if (referer != null && referer.contains("/edit")) {
String postId = referer.substring(referer.indexOf("/post/") + 6, referer.indexOf("/edit"));
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
redirectAttributes.addFlashAttribute("request",
request.getSession().getAttribute("postEditRequest"));
return "redirect:/post/" + postId + "/edit"; // 게시글 ID를 포함한 수정 페이지로 리다이렉트
}
// 작성 페이지인 경우
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
redirectAttributes.addFlashAttribute("request",
request.getSession().getAttribute("postCreateRequest"));
return "redirect:/post/write";
}
@ExceptionHandler(RuntimeException.class)
public String handleRuntimeException(RuntimeException e, Model model) {
model.addAttribute("errorMessage", "예상치 못한 오류가 발생했습니다.");
return "error/server-error";
}
}
class LoginSuccessHandler - 수정
로그인 성공 시 이전 페이지가 존재한다면 이전 페이지로 리다이렉트 처리를 추가하였습니다.
@Component
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
HttpSession session = request.getSession();
session.setAttribute("user", authentication.getPrincipal());
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest == null) {
response.sendRedirect("/");
} else {
String targetUrl = savedRequest.getRedirectUrl();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
}
class WebSecurityConfig - 수정
비회원도 작성, 수정, 삭제 등 여러 가지 로직 처리가 가능하므로 해당 로직의 url을 허용하고 코드 내부 로직을 통해 권한을 검증하게 됩니다.
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/user/signup", "/user/login").permitAll()
.requestMatchers("/manage/user/signup").permitAll()
.requestMatchers("/manage/post/{postId}/comment/**", "/manage/post/**").permitAll()
.requestMatchers("/posts/**", "/post/**").permitAll()
.requestMatchers("/api/photos/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/user/login")
.defaultSuccessUrl("/")
.failureUrl("/user/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.permitAll()
);
return http.build();
추가 내용
Package aop
작성, 수정 실패 처리를 위해 aop 를 도입하며 추가된 패키지입니다.
aop.annotation.SavePostRequest - 추가
해당 처리를 위해 Aspect를 적용할 메서드를 구분하기 위해 추가한 Annotaion입니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SavePostRequest {
}
aop.aspect.PostSessionAspect - 추가
SavePostRequest 어노테이션이 붙은 메서드에 적용될 Aspect입니다. 메서드 수행 전후로 처리할 로직이 존재하여 Around를 사용해 처리해 주었습니다. 처리 로직으로 게시글 등록 및 수정 Post 요청이 오면 실제 저장 및 수정 로직을 실행하기 전 요청으로 넘어온 값들을 세션에 저장해 줍니다. 저장 후 joinPoint의 proceed를 통해 실제 메서드를 실행한 후 로직이 정상 처리 되면 세션에 저장했던 값을 제거해 줍니다. 실패할 경우 GlobalExceptionHandler에 의해 실행 전 세션에 저장했던 값으로 get 요청을 다시 수행하게 됩니다.
@Slf4j
@Aspect
@Component
public class PostSessionAspect {
@Around("@annotation(com.kwang.board.global.aop.annotation.SavePostRequest)")
public Object handlePostSession(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
HttpSession session = null;
PostDTO.Request request = null;
for (Object arg : args) {
if (arg instanceof HttpSession) {
session = (HttpSession) arg;
} else if (arg instanceof PostDTO.Request) {
request = (PostDTO.Request) arg;
}
}
String sessionKey = getSessionKey(joinPoint);
session.setAttribute(sessionKey, request);
Object result = joinPoint.proceed();
session.removeAttribute(sessionKey);
return result;
}
private String getSessionKey(ProceedingJoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
return methodName.contains("update") ? "postEditRequest" : "postCreateRequest";
}
}
class EmbeddedRedisConfig - 추가
테스트 용 Embedded-Redis 사용을 위한 설정 클래스입니다. it.ozimov의 Embedded-Redis 0.7.3 버전을 사용하였습니다.
@Slf4j
@TestConfiguration
public class EmbeddedRedisConfig {
private RedisServer redisServer;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory("127.0.0.1", 6379);
connectionFactory.afterPropertiesSet();
return connectionFactory;
}
@PostConstruct
public void redisServer() throws IOException {
redisServer = new RedisServer(6379);
try {
redisServer.start();
} catch (Exception e) {
log.error("Error starting Redis server", e);
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
}
application.properties - 추가 (테스트)
테스트 환경에서 사용할 application.properties를 추가하였습니다.
#테스트용 h2 데이터베이스 설정 - mysql 방언 사용
spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MySQL;
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create
#Redis 설정
spring.redis.host=localhost
spring.redis.port=6379
#thymeleaf 개발 전 테스트 - thymeleaf 렌더링 x
spring.thymeleaf.enabled=false
#이미지 파일 크기 제한 4MB
spring.servlet.multipart.max-file-size=4MB
spring.servlet.multipart.max-request-size=4MB
'개인 프로젝트 > Toy & Side' 카테고리의 다른 글
[TOY] 개발 - 프론트엔드(User, Admin) (1) | 2025.01.05 |
---|---|
[TOY] 개발 - 수정 (0) | 2025.01.04 |
[TOY] 개발 - Adapters(Comment, Photo) (0) | 2024.12.05 |
[TOY] 개발 - Adapters(Post) (0) | 2024.12.04 |
[TOY] 개발 - Adapters(User) (0) | 2024.12.03 |