💻 뚝딱뚝딱/북북클럽

[개발일지 #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