이번에는 Message API를 추가하였다.
쪽지기능은 회원과 회원 사이에 주고받는 쪽지를 의미한다.
우선 달라진 코드와 중요한 포인트에 대해 알아보자.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers( "/v3/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.antMatchers("/swagger-ui/**", "/v3/**").permitAll() // swagger
.antMatchers("/api/sign-up", "/api/sign-in", "/api/reissue").permitAll()
.antMatchers(HttpMethod.GET, "/api/members").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/messages").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
마지막 쪽에 각 메시지 api에 따른 접근권한을 설정이 추가되었다.
Message
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Message extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "message_id")
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
@OnDelete(action = OnDeleteAction.NO_ACTION)
private Member sender;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id")
@OnDelete(action = OnDeleteAction.NO_ACTION)
private Member receiver;
@Column(nullable = false)
private boolean deletedByReceiver;
@Column(nullable = false)
private boolean deletedBySender;
public Message(String title, String content, Member sender, Member receiver) {
this.title = title;
this.content = content;
this.sender = sender;
this.receiver = receiver;
this.deletedByReceiver = false;
this.deletedBySender = false;
}
public void deleteByReceiver(){
deletedByReceiver = true;
}
public void deleteBySender(){
deletedBySender = true;
}
}
이번에 새로 추가된 메시지 엔티티이다.
딱 필요한 정도인 제목, 내용, 발신자, 수신자를 추가하였다.
발신자와 수신자는 멤버타입으로 메시지와는 다대일 관계이다.
아래 boolean타입으로 발신자가 삭제했는지, 수신자가 삭제했는지 여부를 확인하는 이유는
둘 다 삭제할 경우 데이터베이스에서 해당 Message를 삭제하기 위함이다.
ex) 수신자가 메시지를 삭제해도 발신자는 메시지가 남아있어야 함
MessageController
@Api(value = "Messages Controller", tags = "Messages")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class MessageController {
private final MessageService messageService;
private final MemberRepository memberRepository;
@ApiOperation(value = "편지 작성", notes = "편지 보내기")
@PostMapping("/messages")
@ResponseStatus(HttpStatus.CREATED)
public Response sendMessage(@Valid @RequestBody MessageCreateRequestDto messageCreateRequestDto){
messageService.sendMessage(getPrincipal(),messageCreateRequestDto);
return Response.success();
}
@ApiOperation(value = "받은 편지 전체 조회", notes = "받은 편지 전체 조회")
@GetMapping("/messages/receiver")
@ResponseStatus(HttpStatus.OK)
public Response findALlReceiveMessages(){
return Response.success(messageService.findALlReceiveMessages(getPrincipal()));
}
@ApiOperation(value = "받은 편지 단건 조회", notes = "받은 편지 단건 조회")
@GetMapping("/messages/receiver/{id}")
@ResponseStatus(HttpStatus.OK)
public Response findReceiveMessage(@ApiParam(value = "쪽지 id", required = true)@PathVariable("id") Long id){
return Response.success(messageService.findReceiveMessage(getPrincipal(), id));
}
@ApiOperation(value = "보낸 편지 전체 조회", notes = "받은 편지 전체 조회")
@GetMapping("/messages/sender")
@ResponseStatus(HttpStatus.OK)
public Response findAllSendMessages(){
return Response.success(messageService.findAllSendMessages(getPrincipal()));
}
@ApiOperation(value = "보낸 편지 단건 조회", notes = "보낸 편지 단건 조회")
@GetMapping("/messages/sender/{id}")
@ResponseStatus(HttpStatus.OK)
public Response findSendMessage(@ApiParam(value = "쪽지 id", required = true)@PathVariable("id") Long id){
return Response.success(messageService.findSendMessage(getPrincipal(), id));
}
@ApiOperation(value = "받은 편지 삭제", notes = "받은 편지 삭제")
@DeleteMapping("/messages/receiver/{id}")
@ResponseStatus(HttpStatus.OK)
public Response deleteReceiverMessage(@ApiParam(value = "쪽지 id", required = true)@PathVariable("id") Long id){
messageService.deleteReceiverMessage(getPrincipal(),id);
return Response.success();
}
@ApiOperation(value = "보낸 편지 삭제", notes = "보낸 편지 삭제")
@DeleteMapping("/messages/sender/{id}")
@ResponseStatus(HttpStatus.OK)
public Response deleteSenderMessage(@ApiParam(value = "쪽지 id", required = true)@PathVariable("id") Long id){
messageService.deleteSenderMessage(getPrincipal(),id);
return Response.success();
}
private Member getPrincipal() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return memberRepository.findByUsername(authentication.getName())
.orElseThrow(MemberNotFoundException::new);
}
}
이 부분은 크게 특별할 것이 없다.
MessageCreateRequestDto의 경우 제목, 내용, 받는 사람을 String 타입으로 갖고 있다.
MessageRepository
public interface MessageRepository extends JpaRepository<Message,Long> {
@Query("select m from Message m join fetch m.receiver r " +
"where m.deletedByReceiver=false and r.username =:username order by m.id desc")
List<Message> findAllByReceiverQuery(@Param("username") String username);
@Query("select m from Message m join fetch m.receiver r " +
"where m.deletedByReceiver = false and m.id =:id and r.username=:username")
Optional<Message> findByIdWithReceiver(@Param("id") Long id, @Param("username") String username);
@Query("select m from Message m join fetch m.sender r " +
"where m.deletedBySender=false and r.username =:username order by m.id desc ")
List<Message> findAllBySenderQuery(@Param("username") String username);
@Query("select m from Message m join fetch m.sender r " +
"where m.deletedBySender = false and m.id =:id and r.username=:username")
Optional<Message> findByIdWithSender(@Param("id") Long id, @Param("username") String username);
}
쿼리문을 통해 가져올 데이터가 해당 조회자가 삭제했는지에 따라 필터링을 거치게 해 주었다.
메시지를 가져오고 수신자와 발신자를 가져와 비교해야 하기 때문에, 패치조인을 사용하였다.
N+1문제를 해결하기 위함이다.
가장 최근 메시지가 맨 위에 올라오게 정렬도 적용해 주었다.
MessageService
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository messageRepository;
private final MemberRepository memberRepository;
@Transactional
public void sendMessage(Member sender, MessageCreateRequestDto messageCreateRequestDto){
Member receiver = memberRepository
.findByUsername(messageCreateRequestDto.getReceiverUsername())
.orElseThrow(MemberNotFoundException::new);
Message message = new Message(messageCreateRequestDto.getTitle(),
messageCreateRequestDto.getContent(),
sender,
receiver);
messageRepository.save(message);
}
@Transactional(readOnly = true)
public List<MessageFindAllResponseDto> findALlReceiveMessages(Member member){
List<Message> messages = messageRepository.findAllByReceiverQuery(member.getUsername());
return messages.stream().map(MessageFindAllResponseDto::toDto).collect(Collectors.toList());
}
@Transactional(readOnly = true)
public MessageFindResponseDto findReceiveMessage(Member member,Long id){
Message message = messageRepository.findByIdWithReceiver(id, member.getUsername())
.orElseThrow(MessageNotFoundException::new);
return MessageFindResponseDto.toDto(message);
}
@Transactional(readOnly = true)
public List<MessageFindAllResponseDto> findAllSendMessages(Member member){
List<Message> messages = messageRepository.findAllBySenderQuery(member.getUsername());
return messages.stream().map(MessageFindAllResponseDto::toDto).collect(Collectors.toList());
}
@Transactional(readOnly = true)
public MessageFindResponseDto findSendMessage(Member member,Long id){
Message message = messageRepository.findByIdWithSender(id, member.getUsername())
.orElseThrow(MessageNotFoundException::new);
return MessageFindResponseDto.toDto(message);
}
@Transactional
public void deleteReceiverMessage(Member member,Long id){
Message message = messageRepository.findByIdWithReceiver(id, member.getUsername())
.orElseThrow(MessageNotFoundException::new);
message.deleteByReceiver();
if (message.isDeletedBySender() && message.isDeletedByReceiver()){
messageRepository.delete(message);
}
}
@Transactional
public void deleteSenderMessage(Member member,Long id){
Message message = messageRepository.findByIdWithSender(id, member.getUsername())
.orElseThrow(MessageNotFoundException::new);
message.deleteBySender();
if (message.isDeletedBySender()&& message.isDeletedByReceiver()){
messageRepository.delete(message);
}
}
}
원래 스트림을 좋아하지 않았는데, 공부하다 보니 스트림이 오히려 더 편해졌고 실제 사용하는 편이 코드의 양을 쉽게 줄일 수 있었다.
조회의 경우 4가지가 있다.
- 송신한 메시지 전체 조회
- 송신한 메시지 단건 조회
- 발신한 메시지 전체 조회
- 발신한 메시지 단건 조회
로직을 보면 다 비슷한 로직이다.
이미 리포지토리를 통해 걸러진 데이터를 DTO를 씌워서 보내주는 형태이다.
여기서 스트림을 통해 한번에 DTO를 씌워서 리턴해줄 수 있는데,
이러면 테스트코드를 작성하기 어렵게 된다.
따라서 2번에 나누어 코드를 작성했다.
MessageControllerTest
@ExtendWith(MockitoExtension.class)
class MessageControllerTest {
@InjectMocks
MessageController messageController;
@Mock
MessageService messageService;
@Mock
MemberRepository memberRepository;
ObjectMapper objectMapper = new ObjectMapper();
MockMvc mockMvc;
@BeforeEach
public void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(messageController).build();
}
@Test
public void 메세지보내기테스트() throws Exception {
//given
Member member = createMember();
MessageCreateRequestDto req = new MessageCreateRequestDto("title", "content", "username");
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when
mockMvc.perform(post("/api/messages")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated());
//then
verify(messageService).sendMessage(refEq(member), refEq(req));
}
@Test
public void 받은쪽지_전체조회테스트() throws Exception {
// given
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
get("/api/messages/receiver"))
.andExpect(status().isOk());
//then
verify(messageService).findALlReceiveMessages(member);
}
@Test
public void 받은쪽지_단건조회테스트() throws Exception {
// given
Long id = 1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
get("/api/messages/receiver/{id}", id))
.andExpect(status().isOk());
//then
verify(messageService).findReceiveMessage(member,id);
}
@Test
public void 보낸쪽지_전체조회테스트() throws Exception {
// given
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
get("/api/messages/sender"))
.andExpect(status().isOk());
//then
verify(messageService).findAllSendMessages(member);
}
@Test
public void 보낸쪽지_단건조회테스트() throws Exception {
// given
Long id = 1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
get("/api/messages/sender/{id}", id))
.andExpect(status().isOk());
//then
verify(messageService).findSendMessage(member,id);
}
@Test
public void 받은쪽지_삭제테스트() throws Exception {
// given
Long id = 1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
delete("/api/messages/receiver/{id}", id))
.andExpect(status().isOk());
//then
verify(messageService).deleteReceiverMessage(member,id);
}
@Test
public void 보낸쪽지_삭제테스트() throws Exception {
// given
Long id = 1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
delete("/api/messages/sender/{id}", id))
.andExpect(status().isOk());
//then
verify(messageService).deleteSenderMessage(member,id);
}
}
MessageServiceTest
@ExtendWith(MockitoExtension.class)
public class MessageServiceTest {
@InjectMocks
MessageService messageService;
@Mock
MessageRepository messageRepository;
@Mock
MemberRepository memberRepository;
@Test
public void 쪽지보내기테스트() throws Exception{
//given
Member sender = Member.builder()
.username("sender")
.build();
Member member = createMember();
MessageCreateRequestDto req = new MessageCreateRequestDto("title", "content", "username");
given(memberRepository.findByUsername(req.getReceiverUsername())).willReturn(Optional.of(member));
//when
messageService.sendMessage(sender,req);
//then
verify(messageRepository).save(any());
}
@Test
public void 받은쪽지_전체조회테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
List<Message> messages = new ArrayList<>();
Message message = new Message("title", "content", sender, receiver);
messages.add(message);
given(messageRepository.findAllByReceiverQuery(receiver.getUsername()))
.willReturn(messages);
//when
List<MessageFindAllResponseDto> result = messageService
.findALlReceiveMessages(receiver);
//then
assertThat(result.size()).isEqualTo(1);
}
@Test
public void 받은쪽지_단건조회테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
Message message = createMessage(id,sender,receiver);
given(messageRepository.findByIdWithReceiver(id, receiver.getUsername()))
.willReturn(Optional.of(message));
//when
MessageFindResponseDto result = messageService
.findReceiveMessage(receiver,id);
//then
assertThat(result.getReceiverName()).isEqualTo(receiver.getUsername());
}
@Test
public void 보낸쪽지_전체조회테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
List<Message> messages = new ArrayList<>();
Message message = new Message("title", "content", sender, receiver);
messages.add(message);
given(messageRepository.findAllBySenderQuery(sender.getUsername()))
.willReturn(messages);
//when
List<MessageFindAllResponseDto> result = messageService
.findAllSendMessages(sender);
//then
assertThat(result.size()).isEqualTo(1);
}
@Test
public void 보낸쪽지_단건조회테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
Message message = createMessage(id,sender,receiver);
given(messageRepository.findByIdWithSender(id, sender.getUsername()))
.willReturn(Optional.of(message));
//when
MessageFindResponseDto result = messageService
.findSendMessage(sender,id);
//then
assertThat(result.getSenderName()).isEqualTo(sender.getUsername());
}
@Test
public void 받은편지_삭제테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
Message message = createMessage(id,sender,receiver);
given(messageRepository.findByIdWithReceiver(id, receiver.getUsername()))
.willReturn(Optional.of(message));
//when
messageService.deleteReceiverMessage(receiver,id);
//then
assertThat(message.isDeletedByReceiver()).isTrue();
}
@Test
public void 보낸편지_삭제테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
Message message = createMessage(id,sender,receiver);
given( messageRepository.findByIdWithSender(id, sender.getUsername()))
.willReturn(Optional.of(message));
//when
messageService.deleteSenderMessage(sender,id);
//then
assertThat(message.isDeletedBySender()).isTrue();
}
@Test
public void 편지양쪽_삭제테스트() throws Exception{
//given
Long id = 1L;
Member sender = Member.builder()
.username("sender")
.build();
Member receiver = createMember();
Message message = createMessage(id,sender,receiver);
given( messageRepository.findByIdWithSender(id, sender.getUsername()))
.willReturn(Optional.of(message));
given(messageRepository.findByIdWithReceiver(id, receiver.getUsername()))
.willReturn(Optional.of(message));
//when
messageService.deleteSenderMessage(sender,id);
messageService.deleteReceiverMessage(receiver,id);
//then
verify(messageRepository).delete(message);
}
}
createMessage의 경우 단순히 메시지를 편하게 만들기 위해 만든 메서드이다.
실제 테스트가 잘 통과되는 것을 볼 수 있다.
이제 포스트맨을 이용하여 테스트해 보자.
우선 멤버를 3명 만들어두자.
편지의 작성이 잘되는지 확인해 보자.
backend1로 로그인한 상태이다.
무사히 잘 보내지는 것을 확인할 수 있다.
만약에 없는 회원에게 보내면 어떻게 될까?
예외가 발생하게 된다.
이제 보낸 편지를 확인해 보자.
backend2와 backend3에게 각각 2개의 메시지를 전송한 이후이다.
보낸 편지 단건 조회도 확인해 보자.
이제 내가 보낸 메시지를 삭제해 보고, 그에 따른 조회결과를 확인해 보자.
삭제가 왼료되었다고 나왔다.
실제 조회에서도 우리가 삭제한 메시지는 보이지 않는다.
실제 데이터베이스는 어떨까?
실제 삭제된 것은 아니고, 보낸 사람이 삭제한 것은 1로 바뀌어 있다.
둘 다 삭제할 때 데이터베이스에서 삭제되게 된다.
이제 받은 사람의 입장으로 가보자.
backend2로 로그인을 하고 받은 편지함을 열어보자.
이제 내가 받은 편지를 삭제해 보자.
데이터베이스의 변화가 궁금하니, 발신자가 삭제한 메시지와 동일한 것을 삭제해 보자.
정상적으로 삭제된다.
삭제된 메시지가 보이지 않게 되었다.
실제 데이터 베이스를 확인해 보자.
발신자와 수신자 모두 삭제한 메시지 id값 1번이 사라지게 되었다.
실제 스웨거는 추가된 모습만 확인해 보자.
자세한 코드는 깃허브를 통해 볼 수 있다.
https://github.com/kimtaesoo99/community
'프로젝트 > 커뮤니티' 카테고리의 다른 글
[프로젝트] 커뮤니티 REST API 서버만들기 #6 Comment API만들기 (0) | 2023.01.25 |
---|---|
[프로젝트] 커뮤니티 REST API 서버만들기 #5 게시판 부가기능 추가 (0) | 2023.01.24 |
[프로젝트] 커뮤니티 REST API 서버만들기 #4 Board API만들기 (0) | 2023.01.23 |
[프로젝트]커뮤니티 REST API 서버만들기 #2 - Member API 만들기 (2) | 2023.01.20 |
[프로젝트] 커뮤니티 REST API 서버 만들기 #1 로그인기능 구현 + Swagger (2) | 2023.01.18 |