반응형

이번 시간에는 쪽지 기능을 이어서 구현해보겠습니다.

 

 

controller.message.MessageController를 작성해주겠습니다.

package kukekyakya.kukemarket.controller.message;

import ...

@Api(value = "Message Controller", tags = "Message")
@RestController
@RequiredArgsConstructor
@Slf4j
public class MessageController {
    private final MessageService messageService;

    @ApiOperation(value = "송신자의 쪽지 목록 조회", notes = "송신자의 쪽지 목록을 조회한다.")
    @GetMapping("/api/messages/sender")
    @ResponseStatus(HttpStatus.OK)
    @AssignMemberId
    public Response readAllBySender(@Valid MessageReadCondition cond) {
        return Response.success(messageService.readAllBySender(cond));
    }

    @ApiOperation(value = "수신자의 쪽지 목록 조회", notes = "수신자의 쪽지 목록을 조회한다.")
    @GetMapping("/api/messages/receiver")
    @ResponseStatus(HttpStatus.OK)
    @AssignMemberId
    public Response readAllByReceiver(@Valid MessageReadCondition cond) {
        return Response.success(messageService.readAllByReceiver(cond));
    }

    @ApiOperation(value = "쪽지 조회", notes = "쪽지를 조회한다.")
    @GetMapping("/api/messages/{id}")
    @ResponseStatus(HttpStatus.OK)
    public Response read(@ApiParam(value = "쪽지 id", required = true) @PathVariable Long id) {
        return Response.success(messageService.read(id));
    }

    @ApiOperation(value = "쪽지 생성", notes = "쪽지를 생성한다.")
    @PostMapping("/api/messages")
    @ResponseStatus(HttpStatus.CREATED)
    @AssignMemberId
    public Response create(@Valid @RequestBody MessageCreateRequest req) {
        messageService.create(req);
        return Response.success();
    }

    @ApiOperation(value = "송신자의 쪽지 삭제", notes = "송신자의 쪽지를 삭제한다.")
    @DeleteMapping("/api/messages/sender/{id}")
    @ResponseStatus(HttpStatus.OK)
    public Response deleteBySender(@ApiParam(value = "쪽지 id", required = true) @PathVariable Long id) {
        messageService.deleteBySender(id);
        return Response.success();
    }

    @ApiOperation(value = "수신자의 쪽지 삭제", notes = "수신자의 쪽지를 삭제한다.")
    @DeleteMapping("/api/messages/receiver/{id}")
    @ResponseStatus(HttpStatus.OK)
    public Response deleteByReceiver(@ApiParam(value = "쪽지 id", required = true) @PathVariable Long id) {
        messageService.deleteByReceiver(id);
        return Response.success();
    }
}

목록 조회 요청과 생성 요청은, 서버에서 직접 요청자 id를 주입해줄 수 있도록, @AssignMemberId 어노테이션을 선언해주었습니다.

자세한 설명은 생략하겠습니다.

 

 

 

이제 Spring Security 설정을 해주겠습니다.

쪽지 목록 조회는, memberId 주입이 필요하므로 인증된 사용자가 할 수 있습니다.

쪽지 조회는, 관리자 또는 자원의 소유주가 할 수 있습니다.

쪽지 생성은, 인증된 사용자가 할 수 있습니다.

쪽지 삭제는, 관리자 또는 자원의 소유주가 할 수 있습니다.

SecurityConfig.configure 메소드를 다음과 같이 수정해주겠습니다.

// SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            ...
            .and()
                .authorizeRequests()
                    .antMatchers(HttpMethod.GET, "/api/messages/sender", "/api/messages/receiver").authenticated()
                    .antMatchers(HttpMethod.GET, "/api/messages/{id}").access("@messageGuard.check(#id)")
                    .antMatchers(HttpMethod.POST, "/api/messages").authenticated()
                    .antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").access("@messageSenderGuard.check(#id)")
                    .antMatchers(HttpMethod.DELETE,"/api/messages/receiver/{id}").access("@messageReceiverGuard.check(#id)")
                    .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                    .anyRequest().hasAnyRole("ADMIN")
                ...
}

 

규칙의 순서를 주의합시다. 구체적인 것이 앞서 등록되어야할 것입니다.

 

 

새롭게 작성된 Guard 클래스들은 다음과 같습니다.

package kukekyakya.kukemarket.config.security.guard;

import ...

@Component
@RequiredArgsConstructor
public class MessageGuard extends Guard{
    private final MessageRepository messageRepository;
    private List<RoleType> roleTypes = List.of(RoleType.ROLE_ADMIN);

    @Override
    protected List<RoleType> getRoleTypes() {
        return roleTypes;
    }

    @Override
    protected boolean isResourceOwner(Long id) {
        Message message = messageRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
        return message.getSender().getId().equals(AuthHelper.extractMemberId());
    }
}
package kukekyakya.kukemarket.config.security.guard;

import ...

@Component
@RequiredArgsConstructor
public class MessageSenderGuard extends Guard {
    private final MessageRepository messageRepository;
    private List<RoleType> roleTypes = List.of(RoleType.ROLE_ADMIN);

    @Override
    protected List<RoleType> getRoleTypes() {
        return roleTypes;
    }

    @Override
    protected boolean isResourceOwner(Long id) {
        Message message = messageRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
        return message.getSender().getId().equals(AuthHelper.extractMemberId());
    }
}
package kukekyakya.kukemarket.config.security.guard;

import ...

@Component
@RequiredArgsConstructor
public class MessageReceiverGuard extends Guard {
    private final MessageRepository messageRepository;
    private List<RoleType> roleTypes = List.of(RoleType.ROLE_ADMIN);

    @Override
    protected List<RoleType> getRoleTypes() {
        return roleTypes;
    }

    @Override
    protected boolean isResourceOwner(Long id) {
        Message message = messageRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
        return message.getReceiver().getId().equals(AuthHelper.extractMemberId());
    }
}

지난 시간에 리팩토링하며 작성했던 Guard 추상 클래스를 상속받고, 추상 메소드를 구현하여 간단하게 작성할 수 있습니다.

자세한 설명은 생략하겠습니다.

 

 

이제 테스트를 수행해봅시다.

MessageControllerTest, MessageControllerAdviceTest, MessageControllerIntegrationTest를 작성해주겠습니다.

package kukekyakya.kukemarket.controller.message;

import ...

@ExtendWith(MockitoExtension.class)
class MessageControllerTest {
    @InjectMocks MessageController messageController;
    @Mock MessageService messageService;

    MockMvc mockMvc;
    ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(messageController).build();
    }

    @Test
    void readAllBySenderTest() throws Exception {
        // given
        Long lastMessageId = 1L;
        Integer size = 2;

        // when, then
        mockMvc.perform(
                get("/api/messages/sender")
                        .param("lastMessageId", String.valueOf(lastMessageId))
                        .param("size", String.valueOf(size)))
                .andExpect(status().isOk());

        verify(messageService).readAllBySender(any(MessageReadCondition.class));
    }

    @Test
    void readAllByReceiverTest() throws Exception {
        // given
        Long lastMessageId = 1L;
        Integer size = 2;

        // when, then
        mockMvc.perform(
                get("/api/messages/receiver")
                        .param("lastMessageId", String.valueOf(lastMessageId))
                        .param("size", String.valueOf(size)))
                .andExpect(status().isOk());

        verify(messageService).readAllByReceiver(any(MessageReadCondition.class));
    }

    @Test
    void readTest() throws Exception {
        // given
        Long id = 1L;

        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id))
                .andExpect(status().isOk());
        verify(messageService).read(id);
    }

    @Test
    void createTest() throws Exception {
        // given
        MessageCreateRequest req = createMessageCreateRequest("content", null, 2L);

        // when, then
        mockMvc.perform(
                post("/api/messages")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isCreated());

        verify(messageService).create(req);
    }

    @Test
    void deleteBySenderTest() throws Exception {
        // given
        Long id = 1L;

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id))
                .andExpect(status().isOk());
        verify(messageService).deleteBySender(id);
    }

    @Test
    void deleteByReceiverTest() throws Exception {
        // given
        Long id = 1L;

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id))
                .andExpect(status().isOk());
        verify(messageService).deleteByReceiver(id);
    }

}
package kukekyakya.kukemarket.controller.message;

import ...

@ExtendWith(MockitoExtension.class)
class MessageControllerAdviceTest {
    @InjectMocks MessageController messageController;
    @Mock MessageService messageService;

    MockMvc mockMvc;
    ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(messageController).setControllerAdvice(new ExceptionAdvice()).build();
    }

    @Test
    void readTest() throws Exception {
        // given
        Long id = 1L;
        given(messageService.read(id)).willThrow(MessageNotFoundException.class);

        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1016));
    }

    @Test
    void createTest() throws Exception {
        // given
        MessageCreateRequest req = createMessageCreateRequest("content", null, 2L);
        doThrow(MessageNotFoundException.class).when(messageService).create(req);

        // when, then
        mockMvc.perform(
                post("/api/messages")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1016));
    }

    @Test
    void deleteBySenderTest() throws Exception {
        // given
        Long id = 1L;
        doThrow(MessageNotFoundException.class).when(messageService).deleteBySender(id);

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1016));
    }

    @Test
    void deleteByReceiverTest() throws Exception {
        // given
        Long id = 1L;
        doThrow(MessageNotFoundException.class).when(messageService).deleteByReceiver(id);

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1016));
    }
}
package kukekyakya.kukemarket.controller.message;

import ...

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
public class MessageControllerIntegrationTest {
    @Autowired WebApplicationContext context;
    @Autowired MockMvc mockMvc;

    @Autowired TestInitDB initDB;
    @Autowired SignService signService;
    @Autowired MemberRepository memberRepository;
    @Autowired MessageRepository messageRepository;
    ObjectMapper objectMapper = new ObjectMapper();

    Member admin, sender, receiver;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
        initDB.initDB();
        admin = memberRepository.findByEmail(initDB.getAdminEmail()).orElseThrow(MemberNotFoundException::new);
        sender = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
        receiver = memberRepository.findByEmail(initDB.getMember2Email()).orElseThrow(MemberNotFoundException::new);
    }

    @Test
    void readAllBySenderTest() throws Exception{
        // given
        Integer size = 2;
        SignInResponse signInRes = signService.signIn(createSignInRequest(sender.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                get("/api/messages/sender")
                        .param("size", String.valueOf(size))
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.data.numberOfElements").value(2));
    }

    @Test
    void readAllBySenderUnauthorizedByNoneTokenTest() throws Exception {
        // given
        Integer size = 2;

        // when, then
        mockMvc.perform(
                get("/api/messages/sender")
                        .param("size", String.valueOf(size)))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void readAllByReceiverTest() throws Exception {
        // given
        Integer size = 2;
        SignInResponse signInRes = signService.signIn(createSignInRequest(receiver.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                get("/api/messages/receiver")
                        .param("size", String.valueOf(size))
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.data.numberOfElements").value(2));
    }

    @Test
    void readAllByReceiverUnauthorizedByNoneTokenTest() throws Exception {
        // given
        Integer size = 2;

        // when, then
        mockMvc.perform(
                get("/api/messages/receiver")
                        .param("size", String.valueOf(size)))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void readByResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse signInRes = signService.signIn(createSignInRequest(sender.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id)
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void readByAdminTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse adminSignInRes = signService.signIn(createSignInRequest(admin.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id)
                        .header("Authorization", adminSignInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void readUnauthorizedByNoneTokenTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void readAccessDeniedByNotResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse notResourceOwnerSignInRes = signService.signIn(createSignInRequest(receiver.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                get("/api/messages/{id}", id)
                        .header("Authorization", notResourceOwnerSignInRes.getAccessToken()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/access-denied"));
    }

    @Test
    void createTest() throws Exception {
        // given
        MessageCreateRequest req = createMessageCreateRequest("content", null, receiver.getId());
        SignInResponse signInRes = signService.signIn(createSignInRequest(sender.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                post("/api/messages")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req))
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isCreated());
    }

    @Test
    void createUnauthorizedByNoneTokenTest() throws Exception {
        // given
        MessageCreateRequest req = createMessageCreateRequest("content", null, receiver.getId());

        // when, then
        mockMvc.perform(
                post("/api/messages")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void deleteBySenderByResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse signInRes = signService.signIn(createSignInRequest(sender.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id)
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void deleteBySenderByAdminTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse adminSignInRes = signService.signIn(createSignInRequest(admin.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id)
                        .header("Authorization", adminSignInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void deleteBySenderUnauthorizedByNoneTokenTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void deleteBySenderAccessDeniedByNotResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse notResourceOwnerSignInRes = signService.signIn(createSignInRequest(receiver.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/sender/{id}", id)
                        .header("Authorization", notResourceOwnerSignInRes.getAccessToken()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/access-denied"));
    }

    @Test
    void deleteByReceiverByResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse signInRes = signService.signIn(createSignInRequest(receiver.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id)
                        .header("Authorization", signInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void deleteByReceiverByAdminTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse adminSignInRes = signService.signIn(createSignInRequest(admin.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id)
                        .header("Authorization", adminSignInRes.getAccessToken()))
                .andExpect(status().isOk());
    }

    @Test
    void deleteByReceiverUnauthorizedByNoneTokenTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/entry-point"));
    }

    @Test
    void deleteByReceiverAccessDeniedByNotResourceOwnerTest() throws Exception {
        // given
        Long id = messageRepository.findAll().get(0).getId();
        SignInResponse notResourceOwnerSignInRes = signService.signIn(createSignInRequest(sender.getEmail(), initDB.getPassword()));

        // when, then
        mockMvc.perform(
                delete("/api/messages/receiver/{id}", id)
                        .header("Authorization", notResourceOwnerSignInRes.getAccessToken()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/exception/access-denied"));
    }

}

각 테스트의 자세한 설명은 생략하겠습니다.

 

 

원활한 테스트 작성을 위해 수정된 TestInitDB는 다음과 같습니다.

package kukekyakya.kukemarket.init;

import ...

@Component
public class TestInitDB {
    ...
    @Autowired MessageRepository messageRepository;

    ...

    @Transactional
    public void initDB() {
        ...
    }

    ...

    private void initMessage() {
        Member sender = memberRepository.findByEmail(getMember1Email()).orElseThrow(MemberNotFoundException::new);
        Member receiver = memberRepository.findByEmail(getMember2Email()).orElseThrow(MemberNotFoundException::new);
        IntStream.range(0, 5).forEach(i -> messageRepository.save(new Message("content" + i, sender, receiver)));
    }
    
    ...
}

 5개의 쪽지를 생성해두었습니다.

 

 

모든 테스트를 수행해봅시다.

모든 테스트 성공

깔끔하게 통과되었습니다.

 

 

이번 시간에는 컨트롤러 계층을 작성하며 쪽지 기능을 마저 구현하였습니다. 

이제 각 사용자 간에는 쪽지 조회, 송수신, 삭제할 수 있고, 무한 스크롤을 이용하여 쪽지 목록을 조회할 수 있습니다.

 

 

 

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

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

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

반응형

+ Recent posts