이번 시간에는 컨트롤러 계층을 작성해보겠습니다.
controller.comment.CommentController를 작성해주겠습니다.
package kukekyakya.kukemarket.controller.comment;
import ...
@Api(value = "Comment Controller", tags = "Comment")
@RestController
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
@ApiOperation(value = "댓글 목록 조회", notes = "댓글 목록을 조회한다.")
@GetMapping("/api/comments")
@ResponseStatus(HttpStatus.OK)
public Response readAll(@Valid CommentReadCondition cond) {
return Response.success(commentService.readAll(cond));
}
@ApiOperation(value = "댓글 생성", notes = "댓글을 생성한다.")
@PostMapping("/api/comments")
@ResponseStatus(HttpStatus.CREATED)
@AssignMemberId
public Response create(@Valid @RequestBody CommentCreateRequest req) {
commentService.create(req);
return Response.success();
}
@ApiOperation(value = "댓글 삭제", notes = "댓글을 삭제한다.")
@DeleteMapping("/api/comments/{id}")
@ResponseStatus(HttpStatus.OK)
public Response delete(@ApiParam(value = "댓글 id", required = true) @PathVariable Long id) {
commentService.delete(id);
return Response.success();
}
}
CommentCreateRequest의 memberId를 자동으로 주입하기 위해 @AssignMemberId 어노테이션을 설정하였습니다.
* aop를 이용하는 이에 대한 내용은 다음 링크에서 설명했었습니다.
2021.12.12 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (21) - 게시글 - 생성
Spring Security 설정도 해주겠습니다.
댓글은 누구나 조회할 수 있고, 인증된 사용자만 작성할 수 있으며, 관리자이거나 댓글 작성자 본인일 때만 삭제할 수 있어야합니다.
이번에도 config.security.guard.CommentGuard를 작성하여 세세한 처리를 해주겠습니다.
package kukekyakya.kukemarket.config.security.guard;
import ...
@Component
@RequiredArgsConstructor
@Slf4j
public class CommentGuard {
private final AuthHelper authHelper;
private final CommentRepository commentRepository;
public boolean check(Long id) {
return authHelper.isAuthenticated() && hasAuthority(id);
}
private boolean hasAuthority(Long id) {
return hasAdminRole() || isResourceOwner(id);
}
private boolean isResourceOwner(Long id) {
Comment comment = commentRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
Long memberId = authHelper.extractMemberId();
return comment.getMember().getId().equals(memberId);
}
private boolean hasAdminRole() {
return authHelper.extractMemberRoles().contains(RoleType.ROLE_ADMIN);
}
}
guard 작성은 익숙해지고있으므로, 자세한 설명은 생략하겠습니다.
short circuit evaluation으로 인해 hasAdminRole이 먼저 수행되어야한다는 순서의 필요성은, 지난 게시글 시큐리티 설정에서 언급했었습니다.
새로운 API에 대한 config.security.SecurityConfig 설정도 해주겠습니다.
// SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.and()
.authorizeRequests()
...
.antMatchers(HttpMethod.GET, "/api/**").permitAll()
...
.antMatchers(HttpMethod.POST, "/api/comments").authenticated()
.antMatchers(HttpMethod.DELETE, "/api/comments/{id}").access("@commentGuard.check(#id)")
.anyRequest().hasAnyRole("ADMIN")
...
GET 요청은 누구든지, POST 요청은 인증된 사용자만, DELETE 요청은 guard에 의해 인가된 사용자만 수행할 수 있을 것입니다.
테스트를 진행해봅시다.
CommentControllerTest, CommentControllerAdviceTest, CommentControllerIntegrationTest도 작성해주겠습니다.
package kukekyakya.kukemarket.controller.comment;
import ...
@ExtendWith(MockitoExtension.class)
class CommentControllerTest {
@InjectMocks
CommentController commentController;
@Mock
CommentService commentService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(commentController).build();
}
@Test
void readAllTest() throws Exception {
// given
CommentReadCondition cond = createCommentReadCondition();
// when, then
mockMvc.perform(
get("/api/comments")
.param("postId", String.valueOf(cond.getPostId())))
.andExpect(status().isOk());
verify(commentService).readAll(cond);
}
@Test
void createTest() throws Exception {
// given
CommentCreateRequest req = createCommentCreateRequestWithMemberId(null);
// when, then
mockMvc.perform(
post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
verify(commentService).create(req);
}
@Test
void deleteTest() throws Exception {
// given
Long id = 1L;
// when, then
mockMvc.perform(
delete("/api/comments/{id}", id))
.andExpect(status().isOk());
verify(commentService).delete(id);
}
}
package kukekyakya.kukemarket.controller.comment;
import ...
@ExtendWith(MockitoExtension.class)
class CommentControllerAdviceTest {
@InjectMocks CommentController commentController;
@Mock CommentService commentService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(commentController).setControllerAdvice(new ExceptionAdvice()).build();
}
@Test
void createExceptionByMemberNotFoundTest() throws Exception {
// given
doThrow(MemberNotFoundException.class).when(commentService).create(any());
CommentCreateRequest req = createCommentCreateRequestWithMemberId(null);
// when, then
mockMvc.perform(
post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1007));
}
@Test
void createExceptionByPostNotFoundTest() throws Exception {
// given
doThrow(PostNotFoundException.class).when(commentService).create(any());
CommentCreateRequest req = createCommentCreateRequestWithMemberId(null);
// when, then
mockMvc.perform(
post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1012));
}
@Test
void createExceptionByCommentNotFoundTest() throws Exception {
// given
doThrow(CommentNotFoundException.class).when(commentService).create(any());
CommentCreateRequest req = createCommentCreateRequestWithMemberId(null);
// when, then
mockMvc.perform(
post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1015));
}
@Test
void deleteExceptionByCommentNotFoundTest() throws Exception {
// given
doThrow(CommentNotFoundException.class).when(commentService).delete(anyLong());
Long id = 1L;
// when, then
mockMvc.perform(
delete("/api/comments/{id}", id))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1015));
}
}
package kukekyakya.kukemarket.controller.comment;
import ...
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
class CommentControllerIntegrationTest {
@Autowired WebApplicationContext context;
@Autowired MockMvc mockMvc;
@Autowired TestInitDB initDB;
@Autowired CategoryRepository categoryRepository;
@Autowired MemberRepository memberRepository;
@Autowired CommentRepository commentRepository;
@Autowired CommentService commentService;
@Autowired PostRepository postRepository;
@Autowired SignService signService;
ObjectMapper objectMapper = new ObjectMapper();
Member member1, member2, admin;
Category category;
Post post;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
member1 = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
member2 = memberRepository.findByEmail(initDB.getMember2Email()).orElseThrow(MemberNotFoundException::new);
admin = memberRepository.findByEmail(initDB.getAdminEmail()).orElseThrow(MemberNotFoundException::new);
category = categoryRepository.findAll().get(0);
post = postRepository.save(createPost(member1, category));
}
@Test
void readAllTest() throws Exception {
// given, when, then
mockMvc.perform(
get("/api/comments").param("postId", String.valueOf(1)))
.andExpect(status().isOk());
}
@Test
void createTest() throws Exception {
// given
CommentCreateRequest req = createCommentCreateRequest("content", post.getId(), null, null);
SignInResponse signInRes = signService.signIn(createSignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
post("/api/comments")
.header("Authorization", signInRes.getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
List<CommentDto> result = commentService.readAll(createCommentReadCondition(post.getId()));
assertThat(result.size()).isEqualTo(1);
}
@Test
void createUnauthorizedByNoneTokenTest() throws Exception {
// given
CommentCreateRequest req = createCommentCreateRequest("content", post.getId(), member1.getId(), null);
// when, then
mockMvc.perform(
post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
@Test
void deleteByResourceOwnerTest() throws Exception {
// given
Comment comment = commentRepository.save(createComment(member1, post, null));
SignInResponse signInRes = signService.signIn(createSignInRequest(member1.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/comments/{id}", comment.getId())
.header("Authorization", signInRes.getAccessToken()))
.andExpect(status().isOk());
assertThat(commentRepository.findById(comment.getId())).isEmpty();
}
@Test
void deleteByAdminTest() throws Exception {
// given
Comment comment = commentRepository.save(createComment(member1, post, null));
SignInResponse adminSignInRes = signService.signIn(createSignInRequest(admin.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/comments/{id}", comment.getId())
.header("Authorization", adminSignInRes.getAccessToken()))
.andExpect(status().isOk());
assertThat(commentRepository.findById(comment.getId())).isEmpty();
}
@Test
void deleteUnauthorizedByNoneTokenTest() throws Exception {
// given
Comment comment = commentRepository.save(createComment(member1, post, null));
// when, then
mockMvc.perform(delete("/api/comments/{id}", comment.getId()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
@Test
void deleteAccessDeniedByNotResourceOwnerTest() throws Exception {
// given
Comment comment = commentRepository.save(createComment(member1, post, null));
SignInResponse notOwnerSignInRes = signService.signIn(createSignInRequest(member2.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/comments/{id}", comment.getId())
.header("Authorization", notOwnerSignInRes.getAccessToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
}
테스트는 익숙하므로, 자세한 설명은 생략하겠습니다.
모든 테스트가 통과되었습니다.
포스트맨으로 직접 한 번 실행해봅시다.
테스트를 위해 InitDB에 Comment 초기화 코드도 작성해줍니다.
package kukekyakya.kukemarket;
import ...
@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local")
public class InitDB {
...
private final CommentRepository commentRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initDB() {
...
initComment();
...
}
...
private void initComment() {
Member member = memberRepository.findAll().get(0);
Post post = postRepository.findAll().get(0);
Comment c1 = commentRepository.save(new Comment("content", member, post, null));
Comment c2 = commentRepository.save(new Comment("content", member, post, c1));
Comment c3 = commentRepository.save(new Comment("content", member, post, c1));
Comment c4 = commentRepository.save(new Comment("content", member, post, c2));
Comment c5 = commentRepository.save(new Comment("content", member, post, c2));
Comment c6 = commentRepository.save(new Comment("content", member, post, c4));
Comment c7 = commentRepository.save(new Comment("content", member, post, c3));
Comment c8 = commentRepository.save(new Comment("content", member, post, null));
}
}
단순히 초기화된 첫번째 게시글에, 위와 같은 구조의 계층형 댓글을 만들어주었습니다.
해당 게시글의 댓글 목록을 조회해봅시다.
GET /api/comments?postId=1
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 1,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 2,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 4,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 6,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
},
{
"id": 5,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
},
{
"id": 3,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 7,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
}
]
},
{
"id": 8,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
}
}
계층형으로 댓글이 조회된 것을 확인할 수 있습니다.
4번 댓글을 삭제하고 다시 조회해보겠습니다.
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 1,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 2,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 4,
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 6,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
},
{
"id": 5,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
},
{
"id": 3,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": [
{
"id": 7,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
}
]
},
{
"id": 8,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-19T23:32:38",
"children": []
}
]
}
}
4번 댓글은 삭제 표시만 되어서, 작성자와 본문을 확인할 수 없습니다.
이어서 6번 댓글을 삭제하고 다시 조회해보겠습니다.
4번과 6번 댓글이 모두 삭제되어야할 것입니다.
{
"success": true,
"code": 0,
"result": {
"data": [
{
"id": 1,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": [
{
"id": 2,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": [
{
"id": 5,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": []
}
]
},
{
"id": 3,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": [
{
"id": 7,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": []
}
]
}
]
},
{
"id": 8,
"content": "content",
"member": {
"id": 1,
"email": "admin@admin.com",
"username": "admin",
"nickname": "admin"
},
"createdAt": "2021-12-20T17:40:50",
"children": []
}
]
}
}
정상적으로 삭제된 것을 확인할 수 있습니다.
이번 시간에는 댓글 기능에 대한 컨트롤러 계층을 작성하고, 직접 테스트해보았습니다.
이것으로 댓글 기능은 마무리짓도록 하겠습니다.
이제 정말 기본적인 형태의 게시판 윤곽이 잡히게 되었습니다.
다음으로는, 게시글마다 쪽지를 주고 받을 수 있도록 하고, 이를 조회할 때는 무한 스크롤을 이용하여 페이징을 처리해보려고 합니다.
하지만 그 전에, 불필요한 중복이나 코드의 개선점을 찾아보도록 하겠습니다.
다음 시간에는 코드를 리팩토링하는 시간을 가져보도록 하겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (30) - 코드 리팩토링 (0) | 2021.12.24 |
---|---|
스프링부트 게시판 API 서버 만들기 (29) - 테스트 코드 리팩토링 (0) | 2021.12.20 |
스프링부트 게시판 API 서버 만들기 (27) - 계층형 댓글 - 2 (2) | 2021.12.19 |
스프링부트 게시판 API 서버 만들기 (26) - 계층형 댓글 - 1 (6) | 2021.12.19 |
스프링부트 게시판 API 서버 만들기 (25) - 게시글 목록 조회 - 페이징 및 검색 조건 (0) | 2021.12.15 |