💻 뚝딱뚝딱/북북클럽
[개발일지 #026] 신고(Report) 도메인 구현 및 단위테스트
뚜루리
2025. 5. 1. 10:23
728x90
320x100
🎯 오늘의 목표
- 신고(Report) 도메인 개발
⚙️ 진행한 작업
- 신고(Report) 엔티티 생성
- 신고(Report) 레파지토리 생성
- 신고(Report) 서비스 생성
- 신고(Report) 관련 예외 생성
- 신고(Report) 도메인 단위테스트
🛠️ 개발내용
📌 신고(Report) 엔티티 생성
package ddururi.bookbookclub.domain.report.entity;
import ddururi.bookbookclub.domain.feed.entity.Feed;
import ddururi.bookbookclub.domain.report.enums.ReasonType;
import ddururi.bookbookclub.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 신고(Report) 엔티티
* - 사용자가 특정 피드를 신고한 기록을 저장
* - 사용자(User)와 피드(Feed)와 다대일 관계
*/
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Report {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 신고한 사용자 */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User reporter;
/** 신고된 피드 */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "feed_id", nullable = false)
private Feed feed;
/** 신고 사유 (enum) */
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ReasonType reason;
/** 신고 시간 */
@Column(nullable = false)
private LocalDateTime reportedAt;
private Report(User reporter, Feed feed, ReasonType reason) {
this.reporter = reporter;
this.feed = feed;
this.reason = reason;
this.reportedAt = LocalDateTime.now();
}
/** 신고 생성 정적 메서드 */
public static Report of(User reporter, Feed feed, ReasonType reason) {
return new Report(reporter, feed, reason);
}
}
📌 신고(Report) 관련 enum 생성
package ddururi.bookbookclub.domain.report.enums;
import lombok.Getter;
/**
* 신고 사유 열거형
* - SPAM: 스팸/홍보
* - OFFENSIVE_LANGUAGE: 욕설/모욕
* - INAPPROPRIATE_CONTENT: 부적절한 콘텐츠
* - OTHER: 기타
*/
@Getter
public enum ReasonType {
SPAM("스팸/홍보"),
OFFENSIVE_LANGUAGE("욕설/모욕"),
INAPPROPRIATE_CONTENT("부적절한 콘텐츠"),
OTHER("기타");
private final String description;
ReasonType(String description) {
this.description = description;
}
}
📌 신고(Report) 레파지토리 생성
package ddururi.bookbookclub.domain.report.repository;
import ddururi.bookbookclub.domain.feed.entity.Feed;
import ddururi.bookbookclub.domain.report.entity.Report;
import ddururi.bookbookclub.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 신고 관련 JPA 레포지토리
*/
public interface ReportRepository extends JpaRepository<Report, Long> {
/** 동일 사용자가 이미 해당 피드를 신고했는지 여부 확인 */
boolean existsByReporterAndFeed(User reporter, Feed feed);
}
📌 신고(Report) 서비스 생성
package ddururi.bookbookclub.domain.report.service;
import ddururi.bookbookclub.domain.feed.entity.Feed;
import ddururi.bookbookclub.domain.feed.exception.FeedNotFoundException;
import ddururi.bookbookclub.domain.feed.repository.FeedRepository;
import ddururi.bookbookclub.domain.report.entity.Report;
import ddururi.bookbookclub.domain.report.enums.ReasonType;
import ddururi.bookbookclub.domain.report.exception.AlreadyReportedException;
import ddururi.bookbookclub.domain.report.repository.ReportRepository;
import ddururi.bookbookclub.domain.user.entity.User;
import ddururi.bookbookclub.domain.user.exception.UserNotFoundException;
import ddururi.bookbookclub.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 피드 신고 서비스
*/
@Service
@RequiredArgsConstructor
@Transactional
public class ReportService {
private final ReportRepository reportRepository;
private final FeedRepository feedRepository;
private final UserRepository userRepository;
/** 피드 신고 처리 */
public void reportFeed(Long feedId, Long reporterId, ReasonType reason) {
User reporter = userRepository.findById(reporterId)
.orElseThrow(UserNotFoundException::new);
Feed feed = feedRepository.findById(feedId)
.orElseThrow(FeedNotFoundException::new);
if (reportRepository.existsByReporterAndFeed(reporter, feed)) {
throw new AlreadyReportedException();
}
Report report = Report.of(reporter, feed, reason);
reportRepository.save(report);
}
}
📌 신고(Report) 관련 예외클래스 생성
AlreadyReportedException.java
package ddururi.bookbookclub.domain.report.exception;
import ddururi.bookbookclub.global.exception.ErrorCode;
/**
* 이미 신고한 피드에 대해 다시 신고할 경우 발생하는 예외
*/
public class AlreadyReportedException extends RuntimeException {
private final ErrorCode errorCode = ErrorCode.ALREADY_REPORTED;
public ErrorCode getErrorCode() {
return errorCode;
}
}
ErrorCode.java
package ddururi.bookbookclub.global.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 비즈니스 로직에서 사용하는 에러 코드 정의 enum
* - 예외 코드 문자열 + 사용자 메시지 구성
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
//생략//
ALREADY_REPORTED("R001", "이미 신고한 피드입니다.");
private final String code;
private final String message;
}
GlobalExceptionHandler.java
package ddururi.bookbookclub.global.exception;
/**
* 전역 예외 처리 핸들러
* - 각 예외 상황에 맞는 HTTP 상태코드 및 응답 구조 반환
* - ApiResponse.fail(...) 구조 사용
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
//생략//
@ExceptionHandler(AlreadyReportedException.class)
public ResponseEntity<ApiResponse<?>> handleAlreadyReportedException(AlreadyReportedException ex) {
return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.ALREADY_REPORTED));
}
}
📌 신고(Report) 관련 DTO 생성
package ddururi.bookbookclub.domain.report.dto;
import ddururi.bookbookclub.domain.report.enums.ReasonType;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 피드 신고 요청 DTO
*/
@Getter
@NoArgsConstructor
public class ReportRequest {
/** 신고 사유 */
@NotNull(message = "신고 사유는 필수입니다.")
private ReasonType reason;
}
📌 신고(Report) 단위 테스트
package ddururi.bookbookclub.domain.report.service;
class ReportServiceTest {
private ReportService reportService;
private ReportRepository reportRepository;
private FeedRepository feedRepository;
private UserRepository userRepository;
@BeforeEach
void setUp() {
reportRepository = mock(ReportRepository.class);
feedRepository = mock(FeedRepository.class);
userRepository = mock(UserRepository.class);
reportService = new ReportService(reportRepository, feedRepository, userRepository);
}
@Test
void reportFeed_정상_신고_저장() {
// given
Long reporterId = 1L;
Long feedId = 10L;
ReasonType reason = ReasonType.SPAM;
User user = mock(User.class);
Feed feed = mock(Feed.class);
when(userRepository.findById(reporterId)).thenReturn(Optional.of(user));
when(feedRepository.findById(feedId)).thenReturn(Optional.of(feed));
when(reportRepository.existsByReporterAndFeed(user, feed)).thenReturn(false);
// when
reportService.reportFeed(feedId, reporterId, reason);
// then
ArgumentCaptor<Report> reportCaptor = ArgumentCaptor.forClass(Report.class);
verify(reportRepository, times(1)).save(reportCaptor.capture());
Report savedReport = reportCaptor.getValue();
assertThat(savedReport.getReporter()).isEqualTo(user);
assertThat(savedReport.getFeed()).isEqualTo(feed);
assertThat(savedReport.getReason()).isEqualTo(reason);
}
@Test
void reportFeed_사용자_없음_예외() {
// given
when(userRepository.findById(anyLong())).thenReturn(Optional.empty());
// when & then
assertThrows(UserNotFoundException.class,
() -> reportService.reportFeed(1L, 1L, ReasonType.SPAM));
}
@Test
void reportFeed_피드_없음_예외() {
// given
when(userRepository.findById(anyLong())).thenReturn(Optional.of(mock(User.class)));
when(feedRepository.findById(anyLong())).thenReturn(Optional.empty());
// when & then
assertThrows(FeedNotFoundException.class,
() -> reportService.reportFeed(1L, 1L, ReasonType.SPAM));
}
@Test
void reportFeed_중복_신고_예외() {
// given
User user = mock(User.class);
Feed feed = mock(Feed.class);
when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));
when(feedRepository.findById(anyLong())).thenReturn(Optional.of(feed));
when(reportRepository.existsByReporterAndFeed(user, feed)).thenReturn(true);
// when & then
assertThrows(AlreadyReportedException.class,
() -> reportService.reportFeed(1L, 1L, ReasonType.SPAM));
}
}
728x90
320x100