이번 시간에는 쪽지 기능을 이어서 구현해보겠습니다.
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개의 쪽지를 생성해두었습니다.
모든 테스트를 수행해봅시다.
깔끔하게 통과되었습니다.
이번 시간에는 컨트롤러 계층을 작성하며 쪽지 기능을 마저 구현하였습니다.
이제 각 사용자 간에는 쪽지 조회, 송수신, 삭제할 수 있고, 무한 스크롤을 이용하여 쪽지 목록을 조회할 수 있습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (34) - 국제화 (2) | 2022.01.02 |
---|---|
스프링부트 게시판 API 서버 만들기 (33) - 알람 - 이벤트 다루기 (0) | 2022.01.01 |
스프링부트 게시판 API 서버 만들기 (31) - 쪽지 - 무한 스크롤 - 1 (4) | 2021.12.27 |
스프링부트 게시판 API 서버 만들기 (30) - 코드 리팩토링 (0) | 2021.12.24 |
스프링부트 게시판 API 서버 만들기 (29) - 테스트 코드 리팩토링 (0) | 2021.12.20 |