반응형

이번 시간에는 컨트롤러 계층을 작성해보겠습니다.

 

 

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": []
            }
        ]
    }
}

정상적으로 삭제된 것을 확인할 수 있습니다.

 

 

이번 시간에는 댓글 기능에 대한 컨트롤러 계층을 작성하고, 직접 테스트해보았습니다.

이것으로 댓글 기능은 마무리짓도록 하겠습니다.

 

이제 정말 기본적인 형태의 게시판 윤곽이 잡히게 되었습니다.

다음으로는, 게시글마다 쪽지를 주고 받을 수 있도록 하고, 이를 조회할 때는 무한 스크롤을 이용하여 페이징을 처리해보려고 합니다.

하지만 그 전에, 불필요한 중복이나 코드의 개선점을 찾아보도록 하겠습니다.

 

다음 시간에는 코드를 리팩토링하는 시간을 가져보도록 하겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts