이번 시간에는 사용자 간에 쪽지 송수신 기능을 만들어보겠습니다.
페이지 번호를 이용하여 페이징 처리했던 게시글과는 달리,
각 사용자의 송수신 쪽지 목록은 무한 스크롤을 이용하여 조회할 수 있도록 하겠습니다.
사용자는 조회된 쪽지 목록 스크롤의 끝에 다다르면, 이어서 다음 목록을 받아와야할 것입니다.
무한 스크롤을 어떻게 구현할지 간단하게 살펴보겠습니다.
message_id
1
2
3
4
5
위와 같은 쪽지 송신 내역이 저장되어 있다고 가정하겠습니다.
우리는 최근에 작성된 순서대로 쪽지를 조회할 것입니다.
각 요청마다 2건의 쪽지를 요청하고자 한다면, 첫번째 페이지는 [5, 4]를 받아와야할 것입니다.
이제 다음 페이지를 불러와야한다면, [5, 4] 이후의 쪽지 목록에서 2건을 가져와야합니다.
클라이언트에서는, 자신이 불러왔던 쪽지의 마지막 ID인 4번을 이미 알고 있습니다.
다음 요청에서는, 쪽지의 ID가 4번 '미만'인 목록에서 2건을 가져오면 됩니다.
message_id는 고유하고, 생성된 순서대로 지정되어있기 때문에 가능한 것입니다.
클라이언트(브라우저 등)에서는 스크롤 이벤트를 감지하다가, 스크롤이 페이지의 끝에 다다른다면,
마지막 쪽지의 ID를 이용하여, 다음 쪽지 목록을 요청하면 되는 것입니다.
이제 entity.message.Message 엔티티를 작성해주겠습니다.
package kukekyakya.kukemarket.entity.message;
import ...
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Message extends EntityDate {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
@Lob
private String content;
@Column(nullable = false) // 1
private boolean deletedBySender;
@Column(nullable = false) // 1
private boolean deletedByReceiver;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Member sender;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Member receiver;
public Message(String content, Member sender, Member receiver) {
this.content = content;
this.sender = sender;
this.receiver = receiver;
this.deletedBySender = this.deletedByReceiver = false;
}
public void deleteBySender() { // 2
this.deletedBySender = true;
}
public void deleteByReceiver() { // 2
this.deletedByReceiver = true;
}
public boolean isDeletable() { // 3
return isDeletedBySender() && isDeletedByReceiver();
}
}
1. 각 사용자는 쪽지의 송수신 내역을 조회할 수 있습니다.
결국, 송신자와 수신자는 하나의 쪽지에 대해, 각기 다른 생명 주기를 가지고 있는 것입니다.
송신자와 수신자 모두가 쪽지를 삭제 요청했을 때에만, 실제 데이터베이스에서 제거하도록 하겠습니다.
2. 송신자 또는 수신자가 삭제 요청을 했을 때, 즉시 삭제하는 것이 아니라 누가 삭제를 요청했었는지 표시만 해둡니다.
3. 송신자와 수신자가 모두 삭제 요청을 했다면, 해당 쪽지는 데이터베이스에서 제거해도될 것입니다.
Message 엔티티를 간단히 테스트해주겠습니다.
package kukekyakya.kukemarket.entity.message;
import ...
class MessageTest {
@Test
void deleteBySenderTest() {
// given
Message message = createMessage();
// when
message.deleteBySender();
// then
assertThat(message.isDeletedBySender()).isTrue();
}
@Test
void deleteByReceiverTest() {
// given
Message message = createMessage();
// when
message.deleteByReceiver();
// then
assertThat(message.isDeletedByReceiver()).isTrue();
}
@Test
void isNotDeletableTest() {
// given
Message message = createMessage();
// when
boolean deletable = message.isDeletable();
// then
assertThat(deletable).isFalse();
}
@Test
void isDeletableTest() {
// given
Message message = createMessage();
message.deleteBySender();
message.deleteByReceiver();
// when
boolean deletable = message.isDeletable();
// then
assertThat(deletable).isTrue();
}
}
자세한 설명은 생략하겠습니다.
사용된 팩토리 클래스는 다음과 같습니다.
package kukekyakya.kukemarket.factory.entity;
import ...
public class MessageFactory {
public static Message createMessage() {
return new Message("content", createMember(), createMember());
}
public static Message createMessage(Member sender, Member receiver) {
return new Message("content", sender, receiver);
}
}
자세한 설명은 생략하겠습니다.
이제 repository.message.MessageRepository를 작성해주겠습니다.
package kukekyakya.kukemarket.repository.message;
import ...
public interface MessageRepository extends JpaRepository<Message, Long> {
@Query("select m from Message m left join fetch m.sender left join fetch m.receiver where m.id = :id")
Optional<Message> findWithSenderAndReceiverById(Long id); // 1
// 2
@Query("select new kukekyakya.kukemarket.dto.message.MessageSimpleDto(m.id, m.content, m.receiver.nickname, m.createdAt) " +
"from Message m left join m.receiver " +
"where m.sender.id = :senderId and m.id < :lastMessageId and m.deletedBySender = false order by m.id desc")
Slice<MessageSimpleDto> findAllBySenderIdOrderByMessageIdDesc(Long senderId, Long lastMessageId, Pageable pageable);
// 2
@Query("select new kukekyakya.kukemarket.dto.message.MessageSimpleDto(m.id, m.content, m.sender.nickname, m.createdAt) " +
"from Message m left join m.sender " +
"where m.receiver.id = :receiverId and m.id < :lastMessageId and m.deletedByReceiver = false order by m.id desc")
Slice<MessageSimpleDto> findAllByReceiverIdOrderByMessageIdDesc(Long receiverId, Long lastMessageId, Pageable pageable);
}
1. 단순히 sender와 receiver를 페치 조인하여 단건 조회합니다.
2. 각 사용자의 송신 또는 수신 내역을 조회하는 쿼리입니다.
앞서 살펴봤듯이, 마지막 쪽지 id보다 작은 id 값을 가지는 쪽지만 조회하고 있습니다. 송신자 또는 수신자가 삭제 요청했던 쪽지는 조회되지 않을 것이고, 최근 순으로 정렬하기 위해 쪽지 id로 내림차순 정렬하고 있습니다.
페이징을 처리하기 위해 Pageable도 전달받고 있습니다.
반환 타입은 Slice로 지정해두었는데, 페이징 처리 결과에 대한 다양한 정보를 포함하고 있습니다.
이전에 게시글 조회에서 사용했던 Page와는 달리, 별도의 카운트 쿼리가 수행되지 않고, (Pageable의 지정된 크기 + 1)로 limit 절을 만들어주기 때문에, 다음 페이지가 아직 남아있는지 손쉽게 확인할 수 있습니다.
자세한 사용법은, 테스트를 작성하면서 알아보겠습니다.
쪽지 목록은 응답해야할 데이터가 클 수 있으므로, dto로 즉시 조회하여 프로젝션된 결과를 받도록 하겠습니다.
* 스크롤을 이용한 페이징 처리는, 전체 페이지의 수를 알 필요가 없기 때문에 카운트 쿼리를 수행하지 않아도 될 것입니다. 또한, 지정된 크기만큼 조회하는 것이 아니라 1건을 더 조회하기 때문에, 지정했던 크기와 조회 크기가 다르다면, 다음 페이지가 없다는 것을 바로 알 수 있습니다.
dto.message.MessageSimpleDto는 다음과 같습니다.
package kukekyakya.kukemarket.dto.message;
import ...
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageSimpleDto {
private Long id;
private String content;
private String nickname;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createdAt;
}
단순히 쪽지 id, 내용, 송신자 또는 수신자의 닉네임만 가지고 있습니다.
이제 방금 작성한 MessageRepository를 테스트해보겠습니다.
test 디렉토리에서, 동일한 패키지 경로 내에 MessageRepositoryTest를 작성해줍시다.
package kukekyakya.kukemarket.repository.message;
import ...
@DataJpaTest
@Import(QuerydslConfig.class)
class MessageRepositoryTest {
@Autowired MessageRepository messageRepository;
@Autowired MemberRepository memberRepository;
@PersistenceContext EntityManager em;
Member sender, receiver;
@BeforeEach
void beforeEach() {
sender = memberRepository.save(MemberFactory.createMember("sender@sender.com", "sender", "sender", "sender"));
receiver = memberRepository.save(MemberFactory.createMember("receiver@receiver.com", "receiver", "receiver", "receiver"));
}
@Test
void createAndReadTest() {
// given
Message message = messageRepository.save(createMessage(sender, receiver));
clear();
// when
Message foundMessage = messageRepository.findById(message.getId()).orElseThrow(MessageNotFoundException::new);
// then
assertThat(foundMessage.getId()).isEqualTo(message.getId());
}
@Test
void deleteTest() {
// given
Message message = messageRepository.save(createMessage(sender, receiver));
// when
messageRepository.delete(message);
// then
assertThat(messageRepository.findById(message.getId())).isEmpty();
}
@Test
void deleteCascadeBySenderTest() {
// given
Message message = messageRepository.save(createMessage(sender, receiver));
clear();
// when
memberRepository.deleteById(sender.getId());
clear();
// then
assertThat(messageRepository.findById(message.getId())).isEmpty();
}
@Test
void deleteCascadeByReceiverTest() {
// given
Message message = messageRepository.save(createMessage(sender, receiver));
clear();
// when
memberRepository.deleteById(receiver.getId());
clear();
// then
assertThat(messageRepository.findById(message.getId())).isEmpty();
}
@Test
void findWithSenderAndReceiverByIdTest() {
// given
Message message = messageRepository.save(createMessage(sender, receiver));
clear();
// when
Message foundMessage = messageRepository.findWithSenderAndReceiverById(message.getId()).orElseThrow(MessageNotFoundException::new);
// then
assertThat(foundMessage.getId()).isEqualTo(message.getId());
assertThat(foundMessage.getSender().getEmail()).isEqualTo(sender.getEmail());
assertThat(foundMessage.getReceiver().getEmail()).isEqualTo(receiver.getEmail());
}
@Test
void findAllBySenderIdOrderByMessageIdDescTest() {
// given
List<Message> messages = IntStream.range(0, 4)
.mapToObj(i -> messageRepository.save(createMessage(sender, receiver))).collect(toList());
messages.get(2).deleteBySender();
final int size = 2;
clear();
// when
Slice<MessageSimpleDto> result1 = messageRepository.findAllBySenderIdOrderByMessageIdDesc(sender.getId(), Long.MAX_VALUE, Pageable.ofSize(size));
List<MessageSimpleDto> content1 = result1.getContent();
Long lastMessageId1 = content1.get(content1.size() - 1).getId();
Slice<MessageSimpleDto> result2 = messageRepository.findAllBySenderIdOrderByMessageIdDesc(sender.getId(), lastMessageId1, Pageable.ofSize(size));
List<MessageSimpleDto> content2 = result2.getContent();
// then
assertThat(result1.hasNext()).isTrue();
assertThat(result1.getNumberOfElements()).isEqualTo(2);
assertThat(content1.get(0).getId()).isEqualTo(messages.get(3).getId());
assertThat(content1.get(1).getId()).isEqualTo(messages.get(1).getId());
assertThat(result2.hasNext()).isFalse();
assertThat(result2.getNumberOfElements()).isEqualTo(1);
assertThat(content2.get(0).getId()).isEqualTo(messages.get(0).getId());
}
@Test
void findAllByReceiverIdOrderByMessageIdDescTest() {
// given
List<Message> messages = IntStream.range(0, 4)
.mapToObj(i -> messageRepository.save(createMessage(sender, receiver))).collect(toList());
messages.get(2).deleteByReceiver();
final int size = 2;
clear();
// when
Slice<MessageSimpleDto> result1 = messageRepository.findAllByReceiverIdOrderByMessageIdDesc(receiver.getId(), Long.MAX_VALUE, Pageable.ofSize(size));
List<MessageSimpleDto> content1 = result1.getContent();
Long lastMessageId1 = content1.get(content1.size() - 1).getId();
Slice<MessageSimpleDto> result2 = messageRepository.findAllByReceiverIdOrderByMessageIdDesc(receiver.getId(), lastMessageId1, Pageable.ofSize(size));
List<MessageSimpleDto> content2 = result2.getContent();
// then
assertThat(result1.hasNext()).isTrue();
assertThat(result1.getNumberOfElements()).isEqualTo(2);
assertThat(content1.get(0).getId()).isEqualTo(messages.get(3).getId());
assertThat(content1.get(1).getId()).isEqualTo(messages.get(1).getId());
assertThat(result2.hasNext()).isFalse();
assertThat(result2.getNumberOfElements()).isEqualTo(1);
assertThat(content2.get(0).getId()).isEqualTo(messages.get(0).getId());
}
void clear() {
em.flush();
em.clear();
}
}
테스트는 익숙하므로, 무한 스크롤을 위한 하나의 쿼리 메소드에 대해서만 자세히 살펴보겠습니다.
송신 내역을 조회하는, findAllBySenderIdOrderByMessageIdDescTest 입니다.
@Test
void findAllBySenderIdOrderByMessageIdDescTest() {
// given
List<Message> messages = IntStream.range(0, 4)
.mapToObj(i -> messageRepository.save(createMessage(sender, receiver))).collect(toList());
messages.get(2).deleteBySender();
final int size = 2;
clear();
// when
Slice<MessageSimpleDto> result1 = messageRepository.findAllBySenderIdOrderByMessageIdDesc(sender.getId(), Long.MAX_VALUE, Pageable.ofSize(size));
List<MessageSimpleDto> content1 = result1.getContent();
Long lastMessageId1 = content1.get(content1.size() - 1).getId();
Slice<MessageSimpleDto> result2 = messageRepository.findAllBySenderIdOrderByMessageIdDesc(sender.getId(), lastMessageId1, Pageable.ofSize(size));
List<MessageSimpleDto> content2 = result2.getContent();
// then
assertThat(result1.hasNext()).isTrue();
assertThat(result1.getNumberOfElements()).isEqualTo(2);
assertThat(content1.get(0).getId()).isEqualTo(messages.get(3).getId());
assertThat(content1.get(1).getId()).isEqualTo(messages.get(1).getId());
assertThat(result2.hasNext()).isFalse();
assertThat(result2.getNumberOfElements()).isEqualTo(1);
assertThat(content2.get(0).getId()).isEqualTo(messages.get(0).getId());
}
given : 4건의 쪽지를 생성하고, 1건의 쪽지는 삭제 요청을 보내둔 상태입니다. 각 페이지는 2건씩 조회할 것입니다. 따라서, 2페이지에 걸쳐서 3건의 쪽지가 조회되어야 합니다.
when : 첫 페이지를 조회하고, 마지막 쪽지 id를 이용하여 두번째 페이지를 조회하였습니다.
then : Slice.hasNext를 이용하여 다음 페이지가 있는지 확인하고, Slice.getNumberOfElements를 이용하여 조회된 쪽지의 개수를 확인할 수 있습니다.
생성된 쿼리도 확인해보겠습니다.
Hibernate: select message0_.id as col_0_0_, message0_.content as col_1_0_, member1_.nickname as col_2_0_, message0_.created_at as col_3_0_ from message message0_ left outer join member member1_ on message0_.receiver_id=member1_.member_id where message0_.sender_id=? and message0_.id<? and message0_.deleted_by_sender=0 order by message0_.id desc limit ?
Hibernate: select message0_.id as col_0_0_, message0_.content as col_1_0_, member1_.nickname as col_2_0_, message0_.created_at as col_3_0_ from message message0_ left outer join member member1_ on message0_.receiver_id=member1_.member_id where message0_.sender_id=? and message0_.id<? and message0_.deleted_by_sender=0 order by message0_.id desc limit ?
2건의 SELECT 쿼리가 나간 것을 확인할 수 있습니다. 별도의 카운트 쿼리는 수행되고 있지 않습니다.
이제 service.message.MessageService를 작성해주겠습니다.
package kukekyakya.kukemarket.service.message;
import ...
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MessageService {
private final MessageRepository messageRepository;
private final MemberRepository memberRepository;
public MessageListDto readAllBySender(MessageReadCondition cond) { // 1
return MessageListDto.toDto(
messageRepository.findAllBySenderIdOrderByMessageIdDesc(cond.getMemberId(), cond.getLastMessageId(), Pageable.ofSize(cond.getSize()))
);
}
public MessageListDto readAllByReceiver(MessageReadCondition cond) { // 1
return MessageListDto.toDto(
messageRepository.findAllByReceiverIdOrderByMessageIdDesc(cond.getMemberId(), cond.getLastMessageId(), Pageable.ofSize(cond.getSize()))
);
}
public MessageDto read(Long id) { // 2
return MessageDto.toDto(
messageRepository.findWithSenderAndReceiverById(id).orElseThrow(MessageNotFoundException::new)
);
}
@Transactional
public void create(MessageCreateRequest req) { // 3
messageRepository.save(MessageCreateRequest.toEntity(req, memberRepository));
}
@Transactional
public void deleteBySender(Long id) { // 4
delete(id, Message::deleteBySender);
}
@Transactional
public void deleteByReceiver(Long id) { // 4
delete(id, Message::deleteByReceiver);
}
private void delete(Long id, Consumer<Message> delete) { // 5
Message message = messageRepository.findById(id).orElseThrow(MessageNotFoundException::new);
delete.accept(message);
if(message.isDeletable()) {
messageRepository.delete(message);
}
}
}
송신자와 수신자는, 각기 다른 방식으로 목록 조회와 삭제를 수행해야할 것입니다.
1. 조회 결과로 받은 Slice를, MessageListDto로 변환하여 반환해줍니다.
2. 조회 결과로 받은 Message를, MessageDto로 변환하여 반환해줍니다.
3. 전달 받은 MessageCreateRequest를 Message 엔티티로 변환하여 저장해줍니다.
4. 전달 받은 id의 쪽지를 삭제합니다.
5. 중복 코드를 별도의 메소드로 추출하였습니다. 송신자 또는 수신자에 따라 삭제 표시해두고, 송신자와 수신자 모두가 삭제 요청한 쪽지라면, 데이터베이스에서도 제거해줍니다.
dto.message.MessageReadCondition은 다음과 같습니다.
package kukekyakya.kukemarket.dto.message;
import ...
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageReadCondition {
@ApiModelProperty(hidden = true)
@Null
private Long memberId;
@ApiModelProperty(value = "마지막 쪽지 id", notes = "조회된 쪽지의 마지막 id를 입력해주세요.", required = true, example = "7")
private Long lastMessageId = Long.MAX_VALUE;
@ApiModelProperty(value = "페이지 크기", notes = "페이지 크기를 입력해주세요", required = true, example = "10")
@NotNull(message = "페이지 크기를 입력해주세요.")
@Positive(message = "올바른 페이지 크기를 입력해주세요. (1 이상)")
private Integer size;
}
조회 요청을 보낸 사용자의 id는, 서버에서 직접 주입해줄 것입니다.
마지막 쪽지의 id는 첫번째 요청일 경우 누락될 수 있으므로, Long.MAX_VALUE로 초기화해주었습니다.
자세한 설명은 생략하겠습니다.
dto.message.MessageListDto는 다음과 같습니다.
package kukekyakya.kukemarket.dto.message;
import ...
@Data
@AllArgsConstructor
public class MessageListDto {
private int numberOfElements;
private boolean hasNext;
private List<MessageSimpleDto> messageList;
public static MessageListDto toDto(Slice<MessageSimpleDto> slice) {
return new MessageListDto(slice.getNumberOfElements(), slice.hasNext(), slice.getContent());
}
}
단순히 조회된 쪽지와 다음 페이지가 있는지를 가지고 있습니다.
dto.message.MessageDto는 다음과 같습니다.
package kukekyakya.kukemarket.dto.message;
import ...
@Data
@AllArgsConstructor
public class MessageDto {
private Long id;
private String content;
private MemberDto sender;
private MemberDto receiver;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createdAt;
public static MessageDto toDto(Message message) {
return new MessageDto(
message.getId(),
message.getContent(),
MemberDto.toDto(message.getSender()),
MemberDto.toDto(message.getReceiver()),
message.getCreatedAt());
}
}
자세한 설명은 생략하겠습니다.
dto.message.MessageCreateRequest는 다음과 같습니다.
package kukekyakya.kukemarket.dto.message;
import ...
@ApiModel(value = "쪽지 생성 요청")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageCreateRequest {
@ApiModelProperty(value = "쪽지", notes = "쪽지를 입력해주세요", required = true, example = "my message")
@NotBlank(message = "쪽지를 입력해주세요.")
private String content;
@ApiModelProperty(hidden = true)
@Null
private Long memberId;
@ApiModelProperty(value = "수신자 아이디", notes = "수신자 아이디를 입력해주세요", example = "7")
@NotNull(message = "수신자 아이디를 입력해주세요.")
@Positive(message = "올바른 수신자 아이디를 입력해주세요.")
private Long receiverId;
public static Message toEntity(MessageCreateRequest req, MemberRepository memberRepository) {
return new Message(
req.content,
memberRepository.findById(req.memberId).orElseThrow(MemberNotFoundException::new),
memberRepository.findById(req.receiverId).orElseThrow(MemberNotFoundException::new)
);
}
}
자세한 설명은 생략하겠습니다.
MessageReadCondition과 MessageCreateRequest의 제약 조건을 테스트해주겠습니다.
package kukekyakya.kukemarket.dto.message;
import ...
class MessageReadConditionValidationTest {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void validateTest() {
// given
MessageReadCondition cond = createMessageReadCondition(null, 1L, 1);
// when
Set<ConstraintViolation<MessageReadCondition>> validate = validator.validate(cond);
// then
assertThat(validate).isEmpty();
}
@Test
void invalidateByNotNullMemberIdTest() {
// given
Long invalidValue = 1L;
MessageReadCondition cond = createMessageReadCondition(invalidValue, 1L, 1);
// when
Set<ConstraintViolation<MessageReadCondition>> validate = validator.validate(cond);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNullSizeTest() {
// given
Integer invalidValue = null;
MessageReadCondition cond = createMessageReadCondition(null, 1L, invalidValue);
// when
Set<ConstraintViolation<MessageReadCondition>> validate = validator.validate(cond);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNegativeOrZeroSizeTest() {
// given
Integer invalidValue = 0;
MessageReadCondition cond = createMessageReadCondition(null, 1L, invalidValue);
// when
Set<ConstraintViolation<MessageReadCondition>> validate = validator.validate(cond);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
}
package kukekyakya.kukemarket.dto.message;
import ...
class MessageCreateRequestValidationTest {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void validateTest() {
// given
MessageCreateRequest req = createMessageCreateRequest("content", null, 2L);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isEmpty();
}
@Test
void invalidateByEmptyContentTest() {
// given
String invalidValue = null;
MessageCreateRequest req = createMessageCreateRequest(invalidValue, null, 2L);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByBlankContentTest() {
// given
String invalidValue = " ";
MessageCreateRequest req = createMessageCreateRequest(invalidValue, null, 2L);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNotNullMemberIdTest() {
// given
Long invalidValue = 1L;
MessageCreateRequest req = createMessageCreateRequest("content", invalidValue, 2L);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNullReceiverIdTest() {
// given
Long invalidValue = null;
MessageCreateRequest req = createMessageCreateRequest("content", null, invalidValue);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
@Test
void invalidateByNegativeOrZeroReceiverIdTest() {
// given
Long invalidValue = 0L;
MessageCreateRequest req = createMessageCreateRequest("content", 1L, invalidValue);
// when
Set<ConstraintViolation<MessageCreateRequest>> validate = validator.validate(req);
// then
assertThat(validate).isNotEmpty();
assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
}
}
자세한 설명은 생략하겠습니다.
사용된 팩토리 클래스는 다음과 같습니다.
package kukekyakya.kukemarket.factory.dto;
import ...
public class MessageReadConditionFactory {
public static MessageReadCondition createMessageReadCondition() {
return new MessageReadCondition(1L, 1L, 2);
}
public static MessageReadCondition createMessageReadCondition(Long memberId, Long lastMessageId, Integer size) {
return new MessageReadCondition(memberId, lastMessageId, size);
}
}
package kukekyakya.kukemarket.factory.dto;
import ...
public class MessageCreateRequestFactory {
public static MessageCreateRequest createMessageCreateRequest() {
return new MessageCreateRequest("content", 1L, 2L);
}
public static MessageCreateRequest createMessageCreateRequest(String content, Long memberId, Long receiverId) {
return new MessageCreateRequest(content, memberId, receiverId);
}
}
자세한 설명은 생략하겠습니다.
MessageService도 테스트를 작성해주겠습니다.
package kukekyakya.kukemarket.service.message;
import ...
@ExtendWith(MockitoExtension.class)
class MessageServiceTest {
@InjectMocks MessageService messageService;
@Mock MessageRepository messageRepository;
@Mock MemberRepository memberRepository;
@Test
void readAllBySenderTest() {
// given
MessageReadCondition cond = createMessageReadCondition();
given(messageRepository.findAllBySenderIdOrderByMessageIdDesc(anyLong(), anyLong(), any(Pageable.class)))
.willReturn(new SliceImpl<>(List.of(), Pageable.ofSize(2), false));
// when
MessageListDto result = messageService.readAllBySender(cond);
// then
assertThat(result.getNumberOfElements()).isZero();
assertThat(result.getMessageList().size()).isZero();
assertThat(result.isHasNext()).isFalse();
}
@Test
void readAllByReceiverTest() {
// given
MessageReadCondition cond = createMessageReadCondition();
given(messageRepository.findAllByReceiverIdOrderByMessageIdDesc(anyLong(), anyLong(), any(Pageable.class)))
.willReturn(new SliceImpl<>(List.of(), Pageable.ofSize(2), false));
// when
MessageListDto result = messageService.readAllByReceiver(cond);
// then
assertThat(result.getNumberOfElements()).isZero();
assertThat(result.getMessageList().size()).isZero();
assertThat(result.isHasNext()).isFalse();
}
@Test
void readTest() {
// given
Long id = 1L;
Message message = createMessage();
given(messageRepository.findWithSenderAndReceiverById(id)).willReturn(Optional.of(message));
// when
MessageDto result = messageService.read(id);
// then
assertThat(result.getContent()).isEqualTo(message.getContent());
}
@Test
void readExceptionByMessageNotFoundTest() {
// given
Long id = 1L;
given(messageRepository.findWithSenderAndReceiverById(id)).willReturn(Optional.empty());
// when, then
assertThatThrownBy(() -> messageService.read(id)).isInstanceOf(MessageNotFoundException.class);
}
@Test
void createTest() {
// given
MessageCreateRequest req = createMessageCreateRequest();
given(memberRepository.findById(req.getMemberId())).willReturn(Optional.of(createMember()));
given(memberRepository.findById(req.getReceiverId())).willReturn(Optional.of(createMember()));
// when
messageService.create(req);
// then
verify(messageRepository).save(any());
}
@Test
void createExceptionBySenderNotFoundTest() {
// given
MessageCreateRequest req = createMessageCreateRequest();
given(memberRepository.findById(req.getMemberId())).willReturn(Optional.empty());
// when
assertThatThrownBy(() -> messageService.create(req)).isInstanceOf(MemberNotFoundException.class);
}
@Test
void createExceptionByReceiverNotFoundTest() {
// given
MessageCreateRequest req = createMessageCreateRequest();
given(memberRepository.findById(req.getMemberId())).willReturn(Optional.of(createMember()));
given(memberRepository.findById(req.getReceiverId())).willReturn(Optional.empty());
// when
assertThatThrownBy(() -> messageService.create(req)).isInstanceOf(MemberNotFoundException.class);
}
@Test
void deleteBySenderNotDeletableTest() {
// given
Long id = 1L;
Message message = createMessage();
given(messageRepository.findById(id)).willReturn(Optional.of(message));
// when
messageService.deleteBySender(id);
// then
assertThat(message.isDeletedBySender()).isTrue();
verify(messageRepository, never()).delete(any(Message.class));
}
@Test
void deleteBySenderDeletableByAlreadyReceiverDeletionTest() {
// given
Long id = 1L;
Message message = createMessage();
message.deleteByReceiver();
given(messageRepository.findById(id)).willReturn(Optional.of(message));
// when
messageService.deleteBySender(id);
// then
assertThat(message.isDeletedBySender()).isTrue();
verify(messageRepository).delete(any(Message.class));
}
@Test
void deleteBySenderExceptionByMessageNotFoundTest() {
// given
Long id = 1L;
given(messageRepository.findById(id)).willReturn(Optional.empty());
// when, then
assertThatThrownBy(() -> messageService.deleteBySender(id)).isInstanceOf(MessageNotFoundException.class);
}
@Test
void deleteByReceiverNotDeletableTest() {
// given
Long id = 1L;
Message message = createMessage();
given(messageRepository.findById(id)).willReturn(Optional.of(message));
// when
messageService.deleteByReceiver(id);
// then
assertThat(message.isDeletedByReceiver()).isTrue();
verify(messageRepository, never()).delete(any(Message.class));
}
@Test
void deleteByReceiverDeletableByAlreadySenderDeletionTest() {
// given
Long id = 1L;
Message message = createMessage();
message.deleteBySender();
given(messageRepository.findById(id)).willReturn(Optional.of(message));
// when
messageService.deleteByReceiver(id);
// then
assertThat(message.isDeletedByReceiver()).isTrue();
verify(messageRepository).delete(any(Message.class));
}
@Test
void deleteByReceiverExceptionByMessageNotFoundTest() {
// given
Long id = 1L;
given(messageRepository.findById(id)).willReturn(Optional.empty());
// when, then
assertThatThrownBy(() -> messageService.deleteByReceiver(id)).isInstanceOf(MessageNotFoundException.class);
}
}
테스트는 익숙하므로, 자세한 설명은 생략하겠습니다.
ExceptionAdvice에 MessageNotFoundException을 등록해둡시다.
package kukekyakya.kukemarket.exception;
public class MessageNotFoundException extends RuntimeException {
}
// ExceptionAdvice.java
@ExceptionHandler(MessageNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Response messageNotFoundException() {
return Response.failure(-1016, "존재하지 않는 쪽지입니다.");
}
404 상태 코드를 응답해줄 것입니다.
내용이 길어져서 한번 끊고 가겠습니다.
이번 시간에는, 무한 스크롤을 이용하여 페이징 처리하는 방법에 대하여 논의하였고,
쪽지 기능을 구현하기 위한 엔티티, 리포지토리, 서비스 로직을 작성하였습니다.
다음 시간에는 컨트롤러 계층을 작성해보겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
https://github.com/SongHeeJae/kuke-market
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (33) - 알람 - 이벤트 다루기 (0) | 2022.01.01 |
---|---|
스프링부트 게시판 API 서버 만들기 (32) - 쪽지 - 무한 스크롤 - 2 (0) | 2021.12.27 |
스프링부트 게시판 API 서버 만들기 (30) - 코드 리팩토링 (0) | 2021.12.24 |
스프링부트 게시판 API 서버 만들기 (29) - 테스트 코드 리팩토링 (0) | 2021.12.20 |
스프링부트 게시판 API 서버 만들기 (28) - 계층형 댓글 - 3 (5) | 2021.12.20 |