diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0c44771..0a06875 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,6 +67,7 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_DISCORD_WEBHOOK_REPORT_URL: ${{ secrets.DISCORD_WEBHOOK_REPORT_URL }} V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: @@ -74,7 +75,7 @@ jobs: username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_DISCORD_WEBHOOK_REPORT_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -100,6 +101,7 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'DISCORD_WEBHOOK_REPORT_URL=%s\n' "$V_DISCORD_WEBHOOK_REPORT_URL" >> ~/compose/be.env printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index 218b54c..5200620 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -21,11 +21,21 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.service.AdminService; import com.Coming.Backend.common.response.PageResponse; import com.Coming.Backend.inquiry.entity.InquiryStatus; import com.Coming.Backend.inquiry.entity.InquiryType; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -35,6 +45,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import java.util.List; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -106,6 +117,72 @@ public ResponseEntity updateInquiryStatus( return ResponseEntity.ok().build(); } + @Operation(summary = "전체 공지사항 목록 조회") + @GetMapping("/notices") + public ResponseEntity> getNotices( + @PageableDefault(size = 20) Pageable pageable) { + return ResponseEntity.ok(adminService.getNotices(pageable)); + } + + @Operation(summary = "공지사항 상세 조회 (비활성 포함)") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @GetMapping("/notices/{id}") + public ResponseEntity getAdminNotice(@PathVariable Long id) { + return ResponseEntity.ok(adminService.getAdminNotice(id)); + } + + @Operation(summary = "공지사항 작성") + @PostMapping("/notices") + public ResponseEntity createNotice( + @AuthenticationPrincipal Long adminId, + @RequestBody @Valid AdminNoticeCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminService.createNotice(adminId, request)); + } + + @Operation(summary = "공지사항 수정") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @PatchMapping("/notices/{id}") + public ResponseEntity updateNotice( + @PathVariable Long id, + @RequestBody @Valid AdminNoticeUpdateRequest request) { + adminService.updateNotice(id, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "공지사항 삭제") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @DeleteMapping("/notices/{id}") + public ResponseEntity deleteNotice(@PathVariable Long id) { + adminService.deleteNotice(id); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "전체 신고 목록 조회") + @GetMapping("/reports") + public ResponseEntity> getReports( + @RequestParam(required = false) ReportTargetType targetType, + @RequestParam(required = false) ReportStatus status, + @PageableDefault(size = 20) Pageable pageable) { + return ResponseEntity.ok(adminService.getReports(targetType, status, pageable)); + } + + @Operation(summary = "신고 상세 조회") + @ApiResponse(responseCode = "404", description = "REPORT_NOT_FOUND") + @GetMapping("/reports/{id}") + public ResponseEntity getReportDetail(@PathVariable Long id) { + return ResponseEntity.ok(adminService.getReportDetail(id)); + } + + @Operation(summary = "신고 처리 상태 변경 (deleteTarget=true 시 대상 게시글·댓글 강제 삭제)") + @ApiResponse(responseCode = "404", description = "REPORT_NOT_FOUND") + @PatchMapping("/reports/{id}/status") + public ResponseEntity updateReportStatus( + @PathVariable Long id, + @RequestBody @Valid AdminReportStatusUpdateRequest request) { + adminService.updateReportStatus(id, request); + return ResponseEntity.ok().build(); + } + @Operation(summary = "EXCLUDED 공연 목록 조회") @GetMapping("/concerts/excluded") public ResponseEntity> getExcludedConcerts( diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java new file mode 100644 index 0000000..cc3a02d --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateRequest.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.admin.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminNoticeCreateRequest( + @NotBlank @Size(max = 255) String title, + @NotBlank String content, + Boolean active +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java new file mode 100644 index 0000000..e84c9e5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.admin.dto; + +public record AdminNoticeCreateResponse(Long noticeId) { +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java new file mode 100644 index 0000000..c6b3992 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeDetailResponse.java @@ -0,0 +1,25 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeDetailResponse( + Long id, + String title, + String content, + boolean active, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static AdminNoticeDetailResponse of(Notice notice) { + return new AdminNoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.isActive(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java new file mode 100644 index 0000000..5200f77 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeListItemResponse.java @@ -0,0 +1,21 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeListItemResponse( + Long id, + String title, + boolean active, + LocalDateTime createdAt +) { + public static AdminNoticeListItemResponse of(Notice notice) { + return new AdminNoticeListItemResponse( + notice.getId(), + notice.getTitle(), + notice.isActive(), + notice.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java new file mode 100644 index 0000000..7b6c2fe --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminNoticeUpdateRequest.java @@ -0,0 +1,16 @@ +package com.Coming.Backend.admin.dto; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record AdminNoticeUpdateRequest( + @Pattern(regexp = "(?s).*\\S.*", message = "제목은 공백일 수 없습니다") + @Size(max = 255) + String title, + + @Pattern(regexp = "(?s).*\\S.*", message = "내용은 공백일 수 없습니다") + String content, + + Boolean active +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java new file mode 100644 index 0000000..df1bb8d --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportDetailResponse.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; + +import java.time.LocalDateTime; + +public record AdminReportDetailResponse( + Long id, + ReportTargetType targetType, + Long targetId, + ReportReason reason, + String detail, + ReportStatus status, + String adminNote, + Long reporterId, + String reporterNickname, + LocalDateTime createdAt +) { + public static AdminReportDetailResponse of(Report report, String reporterNickname) { + return new AdminReportDetailResponse( + report.getId(), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.getDetail(), + report.getStatus(), + report.getAdminNote(), + report.getReporterId(), + reporterNickname, + report.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java new file mode 100644 index 0000000..5a4a0e6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportListItemResponse.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; + +import java.time.LocalDateTime; + +public record AdminReportListItemResponse( + Long id, + ReportTargetType targetType, + Long targetId, + ReportReason reason, + ReportStatus status, + Long reporterId, + String reporterNickname, + LocalDateTime createdAt +) { + public static AdminReportListItemResponse of(Report report, String reporterNickname) { + return new AdminReportListItemResponse( + report.getId(), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.getStatus(), + report.getReporterId(), + reporterNickname, + report.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java b/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java new file mode 100644 index 0000000..7b8d8f0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/dto/AdminReportStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.admin.dto; + +import com.Coming.Backend.report.entity.ReportStatus; +import jakarta.validation.constraints.NotNull; + +public record AdminReportStatusUpdateRequest( + @NotNull ReportStatus status, + String adminNote, + Boolean deleteTarget +) { +} diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 00b5fcf..76e7af4 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -24,7 +24,15 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.exception.PipelineConflictException; import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; @@ -62,6 +70,16 @@ import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; +import com.Coming.Backend.post.service.CommentService; +import com.Coming.Backend.post.service.PostService; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -86,6 +104,10 @@ public class AdminService { private final ArtistAliasRepository artistAliasRepository; private final ArtistUrlRepository artistUrlRepository; private final InquiryRepository inquiryRepository; + private final NoticeRepository noticeRepository; + private final ReportRepository reportRepository; + private final PostService postService; + private final CommentService commentService; private final UserRepository userRepository; private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -235,6 +257,116 @@ public void updateInquiryStatus(Long id, AdminInquiryStatusUpdateRequest request inquiry.updateStatus(request.status(), request.adminNote()); } + /** + * 전체 공지사항 목록을 활성 여부 무관하게 조회한다. + */ + public PageResponse getNotices(Pageable pageable) { + return PageResponse.from(noticeRepository.findAll(pageable).map(AdminNoticeListItemResponse::of)); + } + + /** + * 공지사항 상세를 활성 여부 무관하게 조회한다. 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + public AdminNoticeDetailResponse getAdminNotice(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + return AdminNoticeDetailResponse.of(notice); + } + + /** + * 공지사항을 작성한다. active를 지정하지 않으면 기본 활성 상태로 등록한다. + */ + @Transactional + public AdminNoticeCreateResponse createNotice(Long adminId, AdminNoticeCreateRequest request) { + Notice notice = Notice.builder() + .userId(adminId) + .title(request.title()) + .content(request.content()) + .active(request.active() == null || request.active()) + .build(); + noticeRepository.save(notice); + return new AdminNoticeCreateResponse(notice.getId()); + } + + /** + * 공지사항을 수정한다. title·content·active 중 null인 항목은 변경하지 않는다. + * 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + @Transactional + public void updateNotice(Long id, AdminNoticeUpdateRequest request) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + notice.update(request.title(), request.content()); + if (request.active() != null) { + if (request.active()) { + notice.activate(); + } else { + notice.deactivate(); + } + } + } + + /** + * 공지사항을 삭제한다. 존재하지 않는 ID이면 NoticeNotFoundException을 던진다. + */ + @Transactional + public void deleteNotice(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + noticeRepository.delete(notice); + } + + /** + * 전체 신고 목록을 조회한다. targetType·status 중 null인 항목은 필터 없이 조회한다. + */ + public PageResponse getReports(ReportTargetType targetType, ReportStatus status, Pageable pageable) { + Page page; + if (targetType != null && status != null) { + page = reportRepository.findAllByTargetTypeAndStatus(targetType, status, pageable); + } else if (targetType != null) { + page = reportRepository.findAllByTargetType(targetType, pageable); + } else if (status != null) { + page = reportRepository.findAllByStatus(status, pageable); + } else { + page = reportRepository.findAll(pageable); + } + + List reporterIds = page.getContent().stream() + .map(Report::getReporterId) + .distinct() + .toList(); + Map nicknameByUserId = userRepository.findAllByIdIn(reporterIds).stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); + + return PageResponse.from(page.map(report -> + AdminReportListItemResponse.of(report, nicknameByUserId.getOrDefault(report.getReporterId(), "")))); + } + + /** + * 신고 상세를 조회한다. 존재하지 않는 ID이면 ReportNotFoundException을 던진다. + */ + public AdminReportDetailResponse getReportDetail(Long id) { + Report report = reportRepository.findById(id).orElseThrow(ReportNotFoundException::new); + String nickname = userRepository.findById(report.getReporterId()) + .map(User::getNickname) + .orElse(""); + return AdminReportDetailResponse.of(report, nickname); + } + + /** + * 신고 처리 상태를 변경한다. deleteTarget이 true이면 신고 대상 게시글·댓글을 함께 강제 삭제한다. + * 존재하지 않는 ID이면 ReportNotFoundException을 던진다. + */ + @Transactional + public void updateReportStatus(Long id, AdminReportStatusUpdateRequest request) { + Report report = reportRepository.findById(id).orElseThrow(ReportNotFoundException::new); + report.updateStatus(request.status(), request.adminNote()); + if (Boolean.TRUE.equals(request.deleteTarget())) { + if (report.getTargetType() == ReportTargetType.POST) { + postService.adminDelete(report.getTargetId()); + } else { + commentService.adminDelete(report.getTargetId()); + } + } + } + /** * status 제한 없이 공연 단건을 조회한다. EXCLUDED 포함 모든 상태 조회 가능. * diff --git a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java index 8e27d57..b707ea3 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -114,13 +114,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/actuator/health" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/artists/following").hasAnyRole("USER", "ADMIN") + .requestMatchers(HttpMethod.GET, + "/api/concerts/*/rating/me", + "/api/releases/*/rating/me", + "/api/mentions/search" + ).hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/artists/**", "/api/concerts/**", "/api/releases/**", "/api/calendar", - "/api/mentions/search", "/api/posts/**", + "/api/notices/**", "/api/search", "/api/entities/**" ).permitAll() diff --git a/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java b/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java index 43c137f..2a32746 100644 --- a/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java +++ b/src/main/java/com/Coming/Backend/common/discord/DiscordNotificationService.java @@ -3,6 +3,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.util.SecurityContextUtils; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; @@ -37,6 +38,7 @@ public class DiscordNotificationService implements DiscordNotifier { private final ObjectMapper objectMapper; private static final int COLOR_INQUIRY = 0x3498DB; + private static final int COLOR_REPORT = 0xE91E63; @Value("${discord.webhook.5xx-url:}") private String fiveXxUrl; @@ -47,6 +49,9 @@ public class DiscordNotificationService implements DiscordNotifier { @Value("${discord.webhook.inquiry-url:}") private String inquiryUrl; + @Value("${discord.webhook.report-url:}") + private String reportUrl; + public DiscordNotificationService(WebClient.Builder webClientBuilder, RedisTemplate redisTemplate, ObjectMapper objectMapper) { this.webClientBuilder = webClientBuilder; @@ -73,6 +78,11 @@ public void notifyInquiry(Inquiry inquiry) { sendAsync(inquiryUrl, buildInquiryPayload(inquiry)); } + @Override + public void notifyReport(Report report) { + sendAsync(reportUrl, buildReportPayload(report)); + } + private boolean acquireCooldown(String key, Duration ttl) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "1", ttl)); } @@ -136,6 +146,20 @@ private Map buildInquiryPayload(Inquiry inquiry) { ); } + private Map buildReportPayload(Report report) { + return embedPayload( + "🚨 새 신고 접수", + COLOR_REPORT, + List.of( + field("대상", report.getTargetType().name() + " #" + report.getTargetId(), true), + field("사유", report.getReason().name(), true), + field("신고자 userId", String.valueOf(report.getReporterId()), true), + field("상세", truncate(report.getDetail()), false), + field("traceId", resolveTraceId(), true) + ) + ); + } + private Map embedPayload(String title, int color, List> fields) { return Map.of("embeds", List.of(Map.of("title", title, "color", color, "fields", fields))); } diff --git a/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java b/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java index bb04ec9..a7061f1 100644 --- a/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java +++ b/src/main/java/com/Coming/Backend/common/discord/DiscordNotifier.java @@ -2,6 +2,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; public interface DiscordNotifier { @@ -11,4 +12,6 @@ public interface DiscordNotifier { void notifyFourXx(HttpServletRequest request, ErrorCode errorCode); void notifyInquiry(Inquiry inquiry); + + void notifyReport(Report report); } diff --git a/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java b/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java index 5c143e1..531aee3 100644 --- a/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java +++ b/src/main/java/com/Coming/Backend/common/discord/NoOpDiscordNotifier.java @@ -2,6 +2,7 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.entity.Inquiry; +import com.Coming.Backend.report.entity.Report; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -18,4 +19,7 @@ public void notifyFourXx(HttpServletRequest request, ErrorCode errorCode) {} @Override public void notifyInquiry(Inquiry inquiry) {} + + @Override + public void notifyReport(Report report) {} } diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 29abd30..13255f5 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -39,6 +39,7 @@ public enum ErrorCode { ALREADY_IN_CALENDAR(HttpStatus.CONFLICT, "이미 캘린더에 추가된 공연입니다."), NOT_IN_CALENDAR(HttpStatus.BAD_REQUEST, "캘린더에 없는 공연입니다."), CONCERT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 공연입니다."), + CONCERT_NOT_ENDED(HttpStatus.BAD_REQUEST, "공연 종료 후 별점을 등록할 수 있습니다."), // Release RELEASE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 릴리즈입니다."), @@ -61,6 +62,20 @@ public enum ErrorCode { ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요한 댓글입니다."), NOT_LIKED(HttpStatus.BAD_REQUEST, "좋아요하지 않은 댓글입니다."), + // Notice + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), + + // Report + REPORT_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "기타 사유는 상세 내용을 입력해야 합니다."), + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 대상입니다."), + REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고입니다."), + + // Rating + RATING_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 평가 대상입니다."), + INVALID_RATING_SCORE(HttpStatus.BAD_REQUEST, "별점은 0.5~5.0 사이 0.5 단위여야 합니다."), + RATING_NOT_FOUND(HttpStatus.NOT_FOUND, "등록된 별점이 없습니다."), + // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), diff --git a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java index 47a5fec..aee1ca0 100644 --- a/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java +++ b/src/main/java/com/Coming/Backend/concert/controller/ConcertController.java @@ -9,9 +9,14 @@ import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.exception.UnauthorizedException; import com.Coming.Backend.concert.service.ConcertService; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingUpsertRequest; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -20,8 +25,11 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.validation.annotation.Validated; @@ -40,6 +48,7 @@ public class ConcertController { private static final Set SORTABLE_PROPERTIES = Set.of("startDate", "ticketOpenAt"); private final ConcertService concertService; + private final RatingService ratingService; @Operation(summary = "공연 목록 조회") @ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 필드)") @@ -98,4 +107,35 @@ public ResponseEntity> getTicketingConcerts( public ResponseEntity getSetlist(@PathVariable Long id) { return ResponseEntity.ok(concertService.getSetlist(id)); } + + @Operation(summary = "공연 별점 등록·수정") + @ApiResponse(responseCode = "400", description = "INVALID_RATING_SCORE") + @ApiResponse(responseCode = "404", description = "RATING_TARGET_NOT_FOUND") + @PutMapping("/{id}/rating") + public ResponseEntity upsertRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid RatingUpsertRequest request) { + ratingService.upsert(userId, RatingTargetType.CONCERT, id, request.score()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 공연 별점 조회") + @ApiResponse(responseCode = "401", description = "UNAUTHORIZED") + @GetMapping("/{id}/rating/me") + public ResponseEntity getMyRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(ratingService.getMine(userId, RatingTargetType.CONCERT, id)); + } + + @Operation(summary = "공연 별점 취소") + @ApiResponse(responseCode = "404", description = "RATING_NOT_FOUND") + @DeleteMapping("/{id}/rating") + public ResponseEntity deleteRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + ratingService.delete(userId, RatingTargetType.CONCERT, id); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java b/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java index 9c21cf2..63ab40d 100644 --- a/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java +++ b/src/main/java/com/Coming/Backend/concert/dto/ConcertDetailResponse.java @@ -19,6 +19,8 @@ public record ConcertDetailResponse( String price, boolean isInCalendar, LocalDateTime ticketOpenAt, - List ticketLinks + List ticketLinks, + Double averageRating, + long ratingCount ) { } diff --git a/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java b/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java index 4e0d7e9..edaf933 100644 --- a/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java +++ b/src/main/java/com/Coming/Backend/concert/dto/ConcertSummaryResponse.java @@ -16,6 +16,8 @@ public record ConcertSummaryResponse( String venue, ConcertStatus status, boolean isInCalendar, - LocalDateTime ticketOpenAt + LocalDateTime ticketOpenAt, + Double averageRating, + long ratingCount ) { } diff --git a/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java b/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java new file mode 100644 index 0000000..18d00f2 --- /dev/null +++ b/src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.concert.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ConcertNotEndedException extends BusinessException { + + public ConcertNotEndedException() { + super(ErrorCode.CONCERT_NOT_ENDED); + } +} diff --git a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java index bd5d16a..62f8278 100644 --- a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java +++ b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java @@ -28,6 +28,9 @@ import com.Coming.Backend.concert.repository.ConcertRepository; import com.Coming.Backend.concert.repository.SetlistRepository; import com.Coming.Backend.concert.repository.SetlistTrackRepository; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -64,6 +67,7 @@ public class ConcertService { private final UserFollowArtistRepository userFollowArtistRepository; private final SetlistRepository setlistRepository; private final SetlistTrackRepository setlistTrackRepository; + private final RatingService ratingService; /** * 예정·진행 중 공연을 우선 노출하고 이후 조회수 순으로 상위 10건의 인기 공연 목록을 반환한다. @@ -211,6 +215,10 @@ public ConcertDetailResponse getConcert(Long id, Long userId) { boolean isInCalendar = userId != null && userConcertCalendarRepository.existsByUserIdAndConcertId(userId, id); + RatingSummary ratingSummary = ratingService + .getSummaries(RatingTargetType.CONCERT, List.of(id)) + .getOrDefault(id, RatingSummary.empty()); + return new ConcertDetailResponse( concert.getId(), concert.getPosterUrl(), @@ -224,7 +232,9 @@ public ConcertDetailResponse getConcert(Long id, Long userId) { concert.getPrice(), isInCalendar, concert.getTicketOpenAt(), - buildTicketLinks(id) + buildTicketLinks(id), + ratingSummary.averageRating(), + ratingSummary.ratingCount() ); } @@ -240,10 +250,14 @@ private List toConcertSummaryList(List concerts Map artistNameMap = buildArtistNameMap(allArtistIds); Map koreanNameMap = buildKoreanNameMap(List.copyOf(allArtistIds)); Set calendarConcertIds = buildCalendarConcertIds(userId, concertIds); + Map ratingSummaryMap = + ratingService.getSummaries(RatingTargetType.CONCERT, concertIds); return concerts.stream().map(concert -> { List artists = concertToArtistIds.getOrDefault(concert.getId(), List.of()).stream() .map(artistId -> new ArtistSummary(artistId, artistNameMap.get(artistId), koreanNameMap.get(artistId))) .toList(); + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(concert.getId(), RatingSummary.empty()); return new ConcertSummaryResponse( concert.getId(), concert.getPosterUrl(), @@ -254,7 +268,9 @@ private List toConcertSummaryList(List concerts concert.getVenueName(), concert.getStatus(), calendarConcertIds.contains(concert.getId()), - concert.getTicketOpenAt() + concert.getTicketOpenAt(), + ratingSummary.averageRating(), + ratingSummary.ratingCount() ); }).toList(); } diff --git a/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java b/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java new file mode 100644 index 0000000..b393bdb --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/controller/NoticeController.java @@ -0,0 +1,44 @@ +package com.Coming.Backend.notice.controller; + +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.service.NoticeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Notice") +@Validated +@RestController +@RequestMapping("/api/notices") +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeService noticeService; + + @Operation(summary = "커뮤니티 홈 상단 고정용 최근 공지사항 조회") + @GetMapping + public ResponseEntity> getRecent( + @RequestParam(defaultValue = "5") @Min(1) @Max(20) int limit) { + return ResponseEntity.ok(noticeService.getRecent(limit)); + } + + @Operation(summary = "공지사항 상세 조회") + @ApiResponse(responseCode = "404", description = "NOTICE_NOT_FOUND") + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable Long id) { + return ResponseEntity.ok(noticeService.getDetail(id)); + } +} diff --git a/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java b/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java new file mode 100644 index 0000000..699e816 --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/dto/NoticeDetailResponse.java @@ -0,0 +1,12 @@ +package com.Coming.Backend.notice.dto; + +import java.time.LocalDateTime; + +public record NoticeDetailResponse( + Long id, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java b/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java new file mode 100644 index 0000000..e09b66d --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/dto/NoticeSummaryResponse.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.notice.dto; + +import java.time.LocalDateTime; + +public record NoticeSummaryResponse( + Long id, + String title, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/Coming/Backend/notice/entity/Notice.java b/src/main/java/com/Coming/Backend/notice/entity/Notice.java new file mode 100644 index 0000000..d453304 --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/entity/Notice.java @@ -0,0 +1,52 @@ +package com.Coming.Backend.notice.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "notice") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Notice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "active", nullable = false) + private boolean active; + + public void update(String title, String content) { + if (title != null) this.title = title; + if (content != null) this.content = content; + } + + public void activate() { + this.active = true; + } + + public void deactivate() { + this.active = false; + } +} diff --git a/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java b/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java new file mode 100644 index 0000000..b6b397c --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/exception/NoticeNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.notice.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class NoticeNotFoundException extends BusinessException { + + public NoticeNotFoundException() { + super(ErrorCode.NOTICE_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..d2e6426 --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/repository/NoticeRepository.java @@ -0,0 +1,14 @@ +package com.Coming.Backend.notice.repository; + +import com.Coming.Backend.notice.entity.Notice; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + @Query("SELECT n FROM Notice n WHERE n.active = true ORDER BY n.createdAt DESC, n.id DESC") + List findActiveNotices(Pageable pageable); +} diff --git a/src/main/java/com/Coming/Backend/notice/service/NoticeService.java b/src/main/java/com/Coming/Backend/notice/service/NoticeService.java new file mode 100644 index 0000000..87464ba --- /dev/null +++ b/src/main/java/com/Coming/Backend/notice/service/NoticeService.java @@ -0,0 +1,48 @@ +package com.Coming.Backend.notice.service; + +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeService { + + private final NoticeRepository noticeRepository; + + /** + * 활성화된 공지사항을 최신순으로 상위 N개 조회한다. 커뮤니티 홈 상단 고정 노출용. + */ + public List getRecent(int limit) { + List notices = noticeRepository.findActiveNotices(PageRequest.of(0, limit)); + return notices.stream() + .map(notice -> new NoticeSummaryResponse(notice.getId(), notice.getTitle(), notice.getCreatedAt())) + .toList(); + } + + /** + * 공지사항 상세를 조회한다. 비활성 공지는 일반 사용자에게 노출하지 않는다. + */ + public NoticeDetailResponse getDetail(Long id) { + Notice notice = noticeRepository.findById(id).orElseThrow(NoticeNotFoundException::new); + if (!notice.isActive()) { + throw new NoticeNotFoundException(); + } + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/post/controller/PostController.java b/src/main/java/com/Coming/Backend/post/controller/PostController.java index 782fb39..7ebe1a5 100644 --- a/src/main/java/com/Coming/Backend/post/controller/PostController.java +++ b/src/main/java/com/Coming/Backend/post/controller/PostController.java @@ -76,6 +76,14 @@ public ResponseEntity> getPopular( return ResponseEntity.ok(postService.getPopular(days, limit)); } + @Operation(summary = "인기글(추천 임계치 이상) 목록 조회") + @GetMapping("/popular-board") + public ResponseEntity> getPopularBoard( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ResponseEntity.ok(postService.getPopularBoard(page, size)); + } + @Operation(summary = "최근 많이 언급된 태그 조회") @GetMapping("/trending-tags") public ResponseEntity> getTrendingTags( diff --git a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java index 2a6b258..0cc0c18 100644 --- a/src/main/java/com/Coming/Backend/post/repository/PostRepository.java +++ b/src/main/java/com/Coming/Backend/post/repository/PostRepository.java @@ -21,6 +21,9 @@ public interface PostRepository extends JpaRepository { @Query("SELECT p FROM Post p WHERE p.createdAt >= :since ORDER BY p.recommendCount DESC, p.createdAt DESC") List findPopularPosts(@Param("since") LocalDateTime since, Pageable pageable); + @Query("SELECT p FROM Post p WHERE p.recommendCount >= :threshold ORDER BY p.createdAt DESC") + Page findPopularBoard(@Param("threshold") long threshold, Pageable pageable); + @Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id") void incrementViewCount(@Param("id") Long id); diff --git a/src/main/java/com/Coming/Backend/post/service/CommentService.java b/src/main/java/com/Coming/Backend/post/service/CommentService.java index 31a4138..498952e 100644 --- a/src/main/java/com/Coming/Backend/post/service/CommentService.java +++ b/src/main/java/com/Coming/Backend/post/service/CommentService.java @@ -130,6 +130,15 @@ public void delete(Long userId, Long commentId) { comment.softDelete(); } + /** + * 관리자가 작성자 검증 없이 댓글을 강제 삭제(소프트 삭제)한다. 신고 처리 등 관리자 조치용. + */ + @Transactional + public void adminDelete(Long commentId) { + Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + comment.softDelete(); + } + /** * 댓글에 좋아요를 남긴다. 이미 좋아요한 댓글이면 AlreadyLikedException을 던진다. */ diff --git a/src/main/java/com/Coming/Backend/post/service/PostService.java b/src/main/java/com/Coming/Backend/post/service/PostService.java index 4b7076e..a3de5ce 100644 --- a/src/main/java/com/Coming/Backend/post/service/PostService.java +++ b/src/main/java/com/Coming/Backend/post/service/PostService.java @@ -34,6 +34,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -77,6 +78,9 @@ public class PostService { private static final int MIN_SEARCH_QUERY_LENGTH = 2; + @Value("${app.post.popular-board-threshold:10}") + private long popularBoardThreshold; + /** * 게시글을 생성한다. * entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게 @@ -207,6 +211,16 @@ public List getPopular(int days, int limit) { return toSummaryResponses(posts); } + /** + * 추천수가 임계치 이상인 게시글(인기글)을 최신순으로 조회한다. + */ + public PageResponse getPopularBoard(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result = postRepository.findPopularBoard(popularBoardThreshold, pageable); + List content = toSummaryResponses(result.getContent()); + return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); + } + /** * 최근 N일 이내 작성된 게시글에 태그된 엔티티를 언급 빈도 내림차순으로 상위 K개 조회한다. */ @@ -286,6 +300,20 @@ public void delete(Long userId, Long id) { if (!post.isAuthoredBy(userId)) { throw new PostForbiddenException(); } + deletePostAndDependents(post); + } + + /** + * 관리자가 작성자 검증 없이 게시글을 강제 삭제한다. 신고 처리 등 관리자 조치용. + */ + @Transactional + public void adminDelete(Long id) { + Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new); + deletePostAndDependents(post); + } + + private void deletePostAndDependents(Post post) { + Long id = post.getId(); commentLikeRepository.deleteByCommentPostId(id); commentRepository.deleteByPostId(id); postRecommendRepository.deleteByPostId(id); diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java b/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java new file mode 100644 index 0000000..3a07804 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.dto; + +import java.math.BigDecimal; + +public record RatingMeResponse( + BigDecimal score +) { + public static RatingMeResponse empty() { + return new RatingMeResponse(null); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java new file mode 100644 index 0000000..04235c8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.rating.dto; + +public record RatingSummary( + Double averageRating, + long ratingCount +) { + public static RatingSummary empty() { + return new RatingSummary(null, 0); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java b/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java new file mode 100644 index 0000000..3acce29 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java @@ -0,0 +1,15 @@ +package com.Coming.Backend.rating.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record RatingUpsertRequest( + @NotNull + @DecimalMin("0.5") + @DecimalMax("5.0") + BigDecimal score +) { +} diff --git a/src/main/java/com/Coming/Backend/rating/entity/Rating.java b/src/main/java/com/Coming/Backend/rating/entity/Rating.java new file mode 100644 index 0000000..4b17950 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/entity/Rating.java @@ -0,0 +1,48 @@ +package com.Coming.Backend.rating.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "rating") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Rating extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private RatingTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "score", nullable = false) + private BigDecimal score; + + public void updateScore(BigDecimal score) { + this.score = score; + } +} diff --git a/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java b/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java new file mode 100644 index 0000000..add2bca --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.rating.entity; + +public enum RatingTargetType { + CONCERT, RELEASE +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java b/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java new file mode 100644 index 0000000..902aaf3 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class InvalidRatingScoreException extends BusinessException { + + public InvalidRatingScoreException() { + super(ErrorCode.INVALID_RATING_SCORE); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java b/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java new file mode 100644 index 0000000..3ff5d3a --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class RatingNotFoundException extends BusinessException { + + public RatingNotFoundException() { + super(ErrorCode.RATING_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java b/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java new file mode 100644 index 0000000..df18736 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.rating.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class RatingTargetNotFoundException extends BusinessException { + + public RatingTargetNotFoundException() { + super(ErrorCode.RATING_TARGET_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java new file mode 100644 index 0000000..538b348 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -0,0 +1,47 @@ +package com.Coming.Backend.rating.repository; + +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface RatingRepository extends JpaRepository { + + Optional findByUserIdAndTargetTypeAndTargetId( + Long userId, RatingTargetType targetType, Long targetId); + + /** + * user_id·target_type·target_id 유니크 제약을 이용해 별점을 원자적으로 등록·수정한다. + * 동시에 같은 대상에 첫 별점을 등록하는 요청이 몰려도 유니크 제약 위반 없이 하나는 삽입, 나머지는 갱신으로 처리된다. + */ + @Modifying + @Query(value = """ + INSERT INTO rating (user_id, target_type, target_id, score, created_at, updated_at) + VALUES (:userId, :targetType, :targetId, :score, now(), now()) + ON CONFLICT (user_id, target_type, target_id) + DO UPDATE SET score = :score, updated_at = now() + """, nativeQuery = true) + void upsert(@Param("userId") Long userId, @Param("targetType") String targetType, + @Param("targetId") Long targetId, @Param("score") BigDecimal score); + + @Query("SELECT r.targetId AS targetId, AVG(r.score) AS averageScore, COUNT(r) AS ratingCount " + + "FROM Rating r WHERE r.targetType = :targetType " + + "AND r.targetId IN :targetIds GROUP BY r.targetId") + List aggregateByTargetIds(@Param("targetType") RatingTargetType targetType, + @Param("targetIds") Collection targetIds); + + interface RatingAggregate { + Long getTargetId(); + + Double getAverageScore(); + + Long getRatingCount(); + } +} diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java new file mode 100644 index 0000000..f095424 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -0,0 +1,119 @@ +package com.Coming.Backend.rating.service; + +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.exception.ConcertNotEndedException; +import com.Coming.Backend.concert.exception.UnauthorizedException; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.InvalidRatingScoreException; +import com.Coming.Backend.rating.exception.RatingNotFoundException; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.repository.RatingRepository; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RatingService { + + private static final BigDecimal MIN_SCORE = BigDecimal.valueOf(0.5); + private static final BigDecimal MAX_SCORE = BigDecimal.valueOf(5.0); + private static final BigDecimal SCORE_STEP = BigDecimal.valueOf(0.5); + + private final RatingRepository ratingRepository; + private final ConcertRepository concertRepository; + private final ReleaseGroupRepository releaseGroupRepository; + + /** + * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, + * score가 0.5~5.0 범위의 0.5 단위가 아니면 InvalidRatingScoreException, + * 공연이 ENDED 상태가 아니면 ConcertNotEndedException을 던진다. + * + *

등록·수정은 DB의 유니크 제약을 이용한 원자적 upsert로 처리되어, 동일 사용자가 같은 대상에 + * 동시에 첫 별점을 등록해도 유니크 제약 위반 없이 안전하게 처리된다.

+ */ + @Transactional + public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { + if (userId == null) { + throw new UnauthorizedException(); + } + if (score.compareTo(MIN_SCORE) < 0 || score.compareTo(MAX_SCORE) > 0 + || score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { + throw new InvalidRatingScoreException(); + } + validateTarget(targetType, targetId); + ratingRepository.upsert(userId, targetType.name(), targetId, score); + } + + /** + * 내 별점을 조회한다. 등록한 적이 없으면 score가 null인 응답을 반환한다. + */ + public RatingMeResponse getMine(Long userId, RatingTargetType targetType, Long targetId) { + if (userId == null) { + throw new UnauthorizedException(); + } + return ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .map(rating -> new RatingMeResponse(rating.getScore())) + .orElseGet(RatingMeResponse::empty); + } + + /** + * 별점을 취소한다. 등록된 별점이 없으면 RatingNotFoundException을 던진다. + */ + @Transactional + public void delete(Long userId, RatingTargetType targetType, Long targetId) { + if (userId == null) { + throw new UnauthorizedException(); + } + Rating rating = ratingRepository + .findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .orElseThrow(RatingNotFoundException::new); + ratingRepository.delete(rating); + } + + /** + * 대상 ID 목록에 대한 평균 별점·평가 개수를 조회한다. 별점이 없는 대상은 결과 맵에 포함되지 않는다. + */ + public Map getSummaries(RatingTargetType targetType, + Collection targetIds) { + if (targetIds.isEmpty()) { + return Map.of(); + } + return ratingRepository.aggregateByTargetIds(targetType, targetIds).stream() + .collect(Collectors.toMap( + RatingRepository.RatingAggregate::getTargetId, + aggregate -> new RatingSummary( + Math.round(aggregate.getAverageScore() * 10) / 10.0, + aggregate.getRatingCount()) + )); + } + + private void validateTarget(RatingTargetType targetType, Long targetId) { + switch (targetType) { + case CONCERT -> { + Concert concert = concertRepository.findById(targetId) + .orElseThrow(RatingTargetNotFoundException::new); + if (concert.getStatus() != ConcertStatus.ENDED) { + throw new ConcertNotEndedException(); + } + } + case RELEASE -> { + if (!releaseGroupRepository.existsById(targetId)) { + throw new RatingTargetNotFoundException(); + } + } + } + } +} diff --git a/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java b/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java index a9ada41..ee45fad 100644 --- a/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java +++ b/src/main/java/com/Coming/Backend/release/controller/ReleaseController.java @@ -1,19 +1,27 @@ package com.Coming.Backend.release.controller; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingUpsertRequest; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; import com.Coming.Backend.release.service.ReleaseService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,6 +33,7 @@ public class ReleaseController { private final ReleaseService releaseService; + private final RatingService ratingService; @Operation(summary = "릴리즈 목록 조회") @ApiResponse(responseCode = "400", description = "INVALID_INPUT (type이 Album·Single이 아님)") @@ -45,4 +54,35 @@ public ResponseEntity> getReleases( public ResponseEntity getReleaseDetail(@PathVariable Long id) { return ResponseEntity.ok(releaseService.getReleaseDetail(id)); } + + @Operation(summary = "릴리즈 별점 등록·수정") + @ApiResponse(responseCode = "400", description = "INVALID_RATING_SCORE") + @ApiResponse(responseCode = "404", description = "RATING_TARGET_NOT_FOUND") + @PutMapping("/{id}/rating") + public ResponseEntity upsertRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid RatingUpsertRequest request) { + ratingService.upsert(userId, RatingTargetType.RELEASE, id, request.score()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 릴리즈 별점 조회") + @ApiResponse(responseCode = "401", description = "UNAUTHORIZED") + @GetMapping("/{id}/rating/me") + public ResponseEntity getMyRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(ratingService.getMine(userId, RatingTargetType.RELEASE, id)); + } + + @Operation(summary = "릴리즈 별점 취소") + @ApiResponse(responseCode = "404", description = "RATING_NOT_FOUND") + @DeleteMapping("/{id}/rating") + public ResponseEntity deleteRating( + @PathVariable Long id, + @AuthenticationPrincipal Long userId) { + ratingService.delete(userId, RatingTargetType.RELEASE, id); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java b/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java index 20ee93f..1409c5b 100644 --- a/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java +++ b/src/main/java/com/Coming/Backend/release/dto/ReleaseDetailResponse.java @@ -17,9 +17,12 @@ public record ReleaseDetailResponse( String artistName, String artistKoreanName, String spotifyId, - List tracks + List tracks, + Double averageRating, + long ratingCount ) { - public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, String artistKoreanName, List tracks) { + public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, String artistKoreanName, List tracks, + Double averageRating, long ratingCount) { return new ReleaseDetailResponse( release.getId(), release.getTitle(), @@ -32,7 +35,9 @@ public static ReleaseDetailResponse of(ReleaseGroup release, String artistName, artistName, artistKoreanName, release.getSpotifyId(), - tracks + tracks, + averageRating, + ratingCount ); } } diff --git a/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java b/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java index 47710fa..9632c59 100644 --- a/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java +++ b/src/main/java/com/Coming/Backend/release/dto/ReleaseListItemResponse.java @@ -12,9 +12,12 @@ public record ReleaseListItemResponse( String title, String type, LocalDate releaseDate, - String spotifyId + String spotifyId, + Double averageRating, + long ratingCount ) { - public static ReleaseListItemResponse of(ReleaseGroup release, String artistName, String artistKoreanName) { + public static ReleaseListItemResponse of(ReleaseGroup release, String artistName, String artistKoreanName, + Double averageRating, long ratingCount) { return new ReleaseListItemResponse( release.getId(), release.getCoverUrl(), @@ -23,7 +26,9 @@ public static ReleaseListItemResponse of(ReleaseGroup release, String artistName release.getTitle(), release.getType(), release.getFirstReleaseDate(), - release.getSpotifyId() + release.getSpotifyId(), + averageRating, + ratingCount ); } } diff --git a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java index 6382318..4a75042 100644 --- a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java +++ b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java @@ -9,6 +9,9 @@ import com.Coming.Backend.artist.repository.UserFollowArtistRepository; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ArtistReleaseItemResponse; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; @@ -46,6 +49,7 @@ public class ReleaseService { private final ArtistRepository artistRepository; private final ArtistAliasRepository artistAliasRepository; private final UserFollowArtistRepository userFollowArtistRepository; + private final RatingService ratingService; /** * 아티스트의 디스코그래피를 조회한다. types가 비어 있으면 전체 타입을 반환한다. @@ -102,10 +106,18 @@ public PageResponse getReleases(String q, Long artistId Map artistNameMap = artistRepository.findAllById(artistIds).stream() .collect(Collectors.toMap(Artist::getId, Artist::getName)); Map koreanNameMap = buildKoreanNameMap(List.copyOf(artistIds)); - - return PageResponse.from(page.map(release -> - ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId())) - )); + List releaseIds = page.stream().map(ReleaseGroup::getId).toList(); + Map ratingSummaryMap = + ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); + + return PageResponse.from(page.map(release -> { + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(release.getId(), RatingSummary.empty()); + return ReleaseListItemResponse.of(release, + artistNameMap.getOrDefault(release.getArtistId(), ""), + koreanNameMap.get(release.getArtistId()), + ratingSummary.averageRating(), ratingSummary.ratingCount()); + })); } /** @@ -140,7 +152,12 @@ public ReleaseDetailResponse getReleaseDetail(Long id) { List tracks = trackRepository.findByReleaseGroupIdOrderByPosition(release.getId()) .stream().map(TrackDto::from).toList(); - return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks); + RatingSummary ratingSummary = ratingService + .getSummaries(RatingTargetType.RELEASE, List.of(release.getId())) + .getOrDefault(release.getId(), RatingSummary.empty()); + + return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks, + ratingSummary.averageRating(), ratingSummary.ratingCount()); } private Map buildKoreanNameMap(List artistIds) { diff --git a/src/main/java/com/Coming/Backend/report/controller/ReportController.java b/src/main/java/com/Coming/Backend/report/controller/ReportController.java new file mode 100644 index 0000000..1ab216f --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/controller/ReportController.java @@ -0,0 +1,37 @@ +package com.Coming.Backend.report.controller; + +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.service.ReportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Report") +@RestController +@RequestMapping("/api/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "게시글·댓글 신고") + @ApiResponse(responseCode = "400", description = "REPORT_DETAIL_REQUIRED") + @ApiResponse(responseCode = "404", description = "REPORT_TARGET_NOT_FOUND") + @ApiResponse(responseCode = "409", description = "REPORT_ALREADY_EXISTS") + @PostMapping + public ResponseEntity create( + @AuthenticationPrincipal Long userId, + @RequestBody @Valid ReportCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(reportService.create(userId, request)); + } +} diff --git a/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java b/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java new file mode 100644 index 0000000..e2625ea --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/dto/ReportCreateRequest.java @@ -0,0 +1,13 @@ +package com.Coming.Backend.report.dto; + +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import jakarta.validation.constraints.NotNull; + +public record ReportCreateRequest( + @NotNull ReportTargetType targetType, + @NotNull Long targetId, + @NotNull ReportReason reason, + String detail +) { +} diff --git a/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java b/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java new file mode 100644 index 0000000..11374c5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/dto/ReportCreateResponse.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.report.dto; + +public record ReportCreateResponse(Long id) { +} diff --git a/src/main/java/com/Coming/Backend/report/entity/Report.java b/src/main/java/com/Coming/Backend/report/entity/Report.java new file mode 100644 index 0000000..5a91668 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/Report.java @@ -0,0 +1,58 @@ +package com.Coming.Backend.report.entity; + +import com.Coming.Backend.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "report") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Report extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reporter_id", nullable = false) + private Long reporterId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private ReportTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 20) + private ReportReason reason; + + @Column(name = "detail", columnDefinition = "text") + private String detail; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ReportStatus status; + + @Column(name = "admin_note", columnDefinition = "text") + private String adminNote; + + public void updateStatus(ReportStatus status, String adminNote) { + this.status = status; + this.adminNote = adminNote; + } +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportReason.java b/src/main/java/com/Coming/Backend/report/entity/ReportReason.java new file mode 100644 index 0000000..54b57f9 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportReason.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportReason { + SPAM, ABUSE, SEXUAL, ILLEGAL, COPYRIGHT, PRIVACY, ETC +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java b/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java new file mode 100644 index 0000000..a45bd06 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportStatus { + PENDING, RESOLVED, REJECTED +} diff --git a/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java b/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java new file mode 100644 index 0000000..e0984b1 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/entity/ReportTargetType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.report.entity; + +public enum ReportTargetType { + POST, COMMENT +} diff --git a/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java b/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java new file mode 100644 index 0000000..f3cbc2d --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/event/ReportCreatedEvent.java @@ -0,0 +1,6 @@ +package com.Coming.Backend.report.event; + +import com.Coming.Backend.report.entity.Report; + +public record ReportCreatedEvent(Report report) { +} diff --git a/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java b/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java new file mode 100644 index 0000000..6b88e16 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/event/ReportEventListener.java @@ -0,0 +1,19 @@ +package com.Coming.Backend.report.event; + +import com.Coming.Backend.common.discord.DiscordNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ReportEventListener { + + private final DiscordNotifier discordNotifier; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReportCreated(ReportCreatedEvent event) { + discordNotifier.notifyReport(event.report()); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java b/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java new file mode 100644 index 0000000..f7497ed --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportAlreadyExistsException extends BusinessException { + + public ReportAlreadyExistsException() { + super(ErrorCode.REPORT_ALREADY_EXISTS); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java b/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java new file mode 100644 index 0000000..b5129be --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportDetailRequiredException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportDetailRequiredException extends BusinessException { + + public ReportDetailRequiredException() { + super(ErrorCode.REPORT_DETAIL_REQUIRED); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java b/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java new file mode 100644 index 0000000..dd219e2 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportNotFoundException extends BusinessException { + + public ReportNotFoundException() { + super(ErrorCode.REPORT_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java b/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java new file mode 100644 index 0000000..9bcad4e --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/exception/ReportTargetNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.report.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class ReportTargetNotFoundException extends BusinessException { + + public ReportTargetNotFoundException() { + super(ErrorCode.REPORT_TARGET_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java new file mode 100644 index 0000000..32da572 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/repository/ReportRepository.java @@ -0,0 +1,19 @@ +package com.Coming.Backend.report.repository; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, ReportTargetType targetType, Long targetId); + + Page findAllByTargetType(ReportTargetType targetType, Pageable pageable); + + Page findAllByStatus(ReportStatus status, Pageable pageable); + + Page findAllByTargetTypeAndStatus(ReportTargetType targetType, ReportStatus status, Pageable pageable); +} diff --git a/src/main/java/com/Coming/Backend/report/service/ReportService.java b/src/main/java/com/Coming/Backend/report/service/ReportService.java new file mode 100644 index 0000000..3fc2c12 --- /dev/null +++ b/src/main/java/com/Coming/Backend/report/service/ReportService.java @@ -0,0 +1,73 @@ +package com.Coming.Backend.report.service; + +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.event.ReportCreatedEvent; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 게시글·댓글을 신고한다. reason이 ETC인데 detail이 비어 있으면 ReportDetailRequiredException, + * 대상이 존재하지 않으면 ReportTargetNotFoundException, 이미 신고한 대상이면 + * ReportAlreadyExistsException을 던진다. + */ + @Transactional + public ReportCreateResponse create(Long reporterId, ReportCreateRequest request) { + if (request.reason() == ReportReason.ETC && (request.detail() == null || request.detail().isBlank())) { + throw new ReportDetailRequiredException(); + } + if (!targetExists(request.targetType(), request.targetId())) { + throw new ReportTargetNotFoundException(); + } + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, request.targetType(), request.targetId())) { + throw new ReportAlreadyExistsException(); + } + + Report report = Report.builder() + .reporterId(reporterId) + .targetType(request.targetType()) + .targetId(request.targetId()) + .reason(request.reason()) + .detail(request.detail()) + .status(ReportStatus.PENDING) + .build(); + try { + reportRepository.save(report); + } catch (DataIntegrityViolationException e) { + throw new ReportAlreadyExistsException(); + } + + eventPublisher.publishEvent(new ReportCreatedEvent(report)); + return new ReportCreateResponse(report.getId()); + } + + private boolean targetExists(ReportTargetType targetType, Long targetId) { + return switch (targetType) { + case POST -> postRepository.existsById(targetId); + case COMMENT -> commentRepository.existsById(targetId); + }; + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 7b55b7b..6bb578c 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -3,6 +3,7 @@ discord: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + report-url: ${DISCORD_WEBHOOK_REPORT_URL} spring: mail: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b4078e3..f74eccd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -88,6 +88,8 @@ jwt: refresh-token-expiry: 604800000 app: + post: + popular-board-threshold: 10 cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} oauth2: diff --git a/src/main/resources/db/migration/V38__create_notice_table.sql b/src/main/resources/db/migration/V38__create_notice_table.sql new file mode 100644 index 0000000..387242a --- /dev/null +++ b/src/main/resources/db/migration/V38__create_notice_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE notice ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + title varchar(255) NOT NULL, + content text NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamp, + updated_at timestamp +); + +CREATE INDEX idx_notice_active_created_at ON notice (active, created_at); diff --git a/src/main/resources/db/migration/V39__create_report_table.sql b/src/main/resources/db/migration/V39__create_report_table.sql new file mode 100644 index 0000000..8e673bb --- /dev/null +++ b/src/main/resources/db/migration/V39__create_report_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE report ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + reporter_id bigint NOT NULL, + target_type varchar(20) NOT NULL, + target_id bigint NOT NULL, + reason varchar(20) NOT NULL, + detail text, + status varchar(20) NOT NULL, + admin_note text, + created_at timestamp, + updated_at timestamp, + UNIQUE (reporter_id, target_type, target_id) +); + +CREATE INDEX idx_report_status ON report (status); +CREATE INDEX idx_report_target ON report (target_type, target_id); diff --git a/src/main/resources/db/migration/V40__create_rating_table.sql b/src/main/resources/db/migration/V40__create_rating_table.sql new file mode 100644 index 0000000..ba84f34 --- /dev/null +++ b/src/main/resources/db/migration/V40__create_rating_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE rating ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + target_type varchar(20) NOT NULL, + target_id bigint NOT NULL, + score numeric(2,1) NOT NULL, + created_at timestamp NOT NULL, + updated_at timestamp NOT NULL, + UNIQUE (user_id, target_type, target_id) +); + +CREATE INDEX idx_rating_target ON rating (target_type, target_id); diff --git a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java index 7d02964..01730c7 100644 --- a/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/Coming/Backend/admin/controller/AdminControllerTest.java @@ -27,6 +27,14 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; import com.Coming.Backend.admin.dto.DataConcertSearchResult; import com.Coming.Backend.admin.dto.PipelineArtistCollectResult; @@ -44,12 +52,19 @@ import com.Coming.Backend.inquiry.entity.InquiryStatus; import com.Coming.Backend.inquiry.entity.InquiryType; import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -59,6 +74,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -79,6 +98,10 @@ class AdminControllerTest { private static final Long USER_ID = 10L; private static final Long TARGET_ID = 99L; private static final Long CONCERT_ID = 1L; + private static final Long NOTICE_ID = 1L; + private static final Long ADMIN_ID = 5L; + private static final Long REPORT_ID = 1L; + private static final Long REPORTER_ID = 30L; @BeforeEach void setUp() { @@ -86,9 +109,22 @@ void setUp() { objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mockMvc = MockMvcBuilders.standaloneSetup(adminController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver()) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + ADMIN_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } // ------------------------------------------------------------------------- @@ -791,4 +827,316 @@ void should_return_400_when_link_type_is_blank_on_update() throws Exception { .content(requestBody)) .andExpect(status().isBadRequest()); } + + // ------------------------------------------------------------------------- + // GET /api/admin/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_list_when_notices_exist() throws Exception { + // given + AdminNoticeListItemResponse item = new AdminNoticeListItemResponse( + NOTICE_ID, "점검 안내", true, LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getNotices(any(Pageable.class))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/notices").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(NOTICE_ID)) + .andExpect(jsonPath("$.content[0].title").value("점검 안내")) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").isNumber()); + } + + // ------------------------------------------------------------------------- + // GET /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_detail_when_valid_id_given() throws Exception { + // given + AdminNoticeDetailResponse detail = new AdminNoticeDetailResponse( + NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true, + LocalDateTime.of(2025, 8, 20, 0, 0), LocalDateTime.of(2025, 8, 20, 0, 0) + ); + given(adminService.getAdminNotice(NOTICE_ID)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/notices/{id}", NOTICE_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(NOTICE_ID)) + .andExpect(jsonPath("$.title").value("점검 안내")) + .andExpect(jsonPath("$.content").value("9월 20일 점검이 진행됩니다.")) + .andExpect(jsonPath("$.active").value(true)); + } + + @Test + void should_return_404_when_notice_not_found_on_detail() throws Exception { + // given + given(adminService.getAdminNotice(999L)).willThrow(new NoticeNotFoundException()); + + // when & then + mockMvc.perform(get("/api/admin/notices/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // POST /api/admin/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_valid_notice_create_request_given() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내", + "content": "9월 20일 점검이 진행됩니다." + } + """; + given(adminService.createNotice(eq(ADMIN_ID), any(AdminNoticeCreateRequest.class))) + .willReturn(new AdminNoticeCreateResponse(NOTICE_ID)); + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.noticeId").value(NOTICE_ID)); + } + + @Test + void should_return_400_when_title_is_blank_on_notice_create() throws Exception { + // given + String requestBody = """ + { + "title": "", + "content": "9월 20일 점검이 진행됩니다." + } + """; + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_400_when_content_is_blank_on_notice_create() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내", + "content": "" + } + """; + + // when & then + mockMvc.perform(post("/api/admin/notices") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + // ------------------------------------------------------------------------- + // PATCH /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_notice_update_request_given() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내 (수정)" + } + """; + willDoNothing().given(adminService).updateNotice(eq(NOTICE_ID), any(AdminNoticeUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{id}", NOTICE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()); + } + + @Test + void should_return_404_when_notice_not_found_on_update() throws Exception { + // given + String requestBody = """ + { + "title": "점검 안내 (수정)" + } + """; + willThrow(new NoticeNotFoundException()) + .given(adminService).updateNotice(eq(999L), any(AdminNoticeUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{id}", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // DELETE /api/admin/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_204_when_notice_deleted_successfully() throws Exception { + // given + willDoNothing().given(adminService).deleteNotice(NOTICE_ID); + + // when & then + mockMvc.perform(delete("/api/admin/notices/{id}", NOTICE_ID)) + .andExpect(status().isNoContent()); + } + + @Test + void should_return_404_when_notice_not_found_on_delete() throws Exception { + // given + willThrow(new NoticeNotFoundException()).given(adminService).deleteNotice(999L); + + // when & then + mockMvc.perform(delete("/api/admin/notices/{id}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/admin/reports + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_report_list_when_no_filter_given() throws Exception { + // given + AdminReportListItemResponse item = new AdminReportListItemResponse( + REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, ReportStatus.PENDING, + REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getReports(isNull(), isNull(), any(Pageable.class))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/reports").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(REPORT_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").isNumber()); + } + + @Test + void should_pass_target_type_and_status_filter_to_service_when_filter_params_given() throws Exception { + // given + AdminReportListItemResponse item = new AdminReportListItemResponse( + REPORT_ID, ReportTargetType.COMMENT, TARGET_ID, ReportReason.ABUSE, ReportStatus.RESOLVED, + REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) + ); + PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); + given(adminService.getReports(eq(ReportTargetType.COMMENT), eq(ReportStatus.RESOLVED), any(Pageable.class))) + .willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/admin/reports") + .param("targetType", "COMMENT") + .param("status", "RESOLVED") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].targetType").value("COMMENT")) + .andExpect(jsonPath("$.content[0].status").value("RESOLVED")); + } + + // ------------------------------------------------------------------------- + // GET /api/admin/reports/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_report_detail_when_valid_id_given() throws Exception { + // given + AdminReportDetailResponse detail = new AdminReportDetailResponse( + REPORT_ID, ReportTargetType.POST, TARGET_ID, ReportReason.SPAM, "광고성 게시글입니다.", + ReportStatus.PENDING, null, REPORTER_ID, "신고자닉네임", LocalDateTime.of(2025, 8, 20, 0, 0) + ); + given(adminService.getReportDetail(REPORT_ID)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/reports/{id}", REPORT_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(REPORT_ID)) + .andExpect(jsonPath("$.targetId").value(TARGET_ID)) + .andExpect(jsonPath("$.detail").value("광고성 게시글입니다.")) + .andExpect(jsonPath("$.reporterId").value(REPORTER_ID)) + .andExpect(jsonPath("$.reporterNickname").value("신고자닉네임")); + } + + @Test + void should_return_404_when_report_not_found_on_detail() throws Exception { + // given + given(adminService.getReportDetail(999L)).willThrow(new ReportNotFoundException()); + + // when & then + mockMvc.perform(get("/api/admin/reports/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // PATCH /api/admin/reports/{id}/status + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_report_status_updated_successfully() throws Exception { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + willDoNothing().given(adminService).updateReportStatus(eq(REPORT_ID), any(AdminReportStatusUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", REPORT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void should_return_400_when_status_field_is_null_on_report_status_update() throws Exception { + // given — status 필드 누락 + String requestBody = """ + { + "adminNote": "삭제 처리" + } + """; + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", REPORT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_report_not_found_on_status_update() throws Exception { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, null, null); + willThrow(new ReportNotFoundException()) + .given(adminService).updateReportStatus(eq(999L), any(AdminReportStatusUpdateRequest.class)); + + // when & then + mockMvc.perform(patch("/api/admin/reports/{id}/status", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } } diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index d318cec..cd5259d 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -15,7 +15,15 @@ import com.Coming.Backend.admin.dto.AdminInquiryDetailResponse; import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateRequest; +import com.Coming.Backend.admin.dto.AdminNoticeCreateResponse; +import com.Coming.Backend.admin.dto.AdminNoticeDetailResponse; +import com.Coming.Backend.admin.dto.AdminNoticeListItemResponse; +import com.Coming.Backend.admin.dto.AdminNoticeUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.dto.AdminReportDetailResponse; +import com.Coming.Backend.admin.dto.AdminReportListItemResponse; +import com.Coming.Backend.admin.dto.AdminReportStatusUpdateRequest; import com.Coming.Backend.admin.dto.BookingLinkRequest; import com.Coming.Backend.admin.dto.DataArtistSearchResult; import com.Coming.Backend.admin.dto.DataConcertSearchResult; @@ -60,6 +68,17 @@ import com.Coming.Backend.inquiry.exception.InquiryNotFoundException; import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; +import com.Coming.Backend.post.service.CommentService; +import com.Coming.Backend.post.service.PostService; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; import com.Coming.Backend.common.exception.InvalidInputException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -105,6 +124,18 @@ class AdminServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private NoticeRepository noticeRepository; + + @Mock + private ReportRepository reportRepository; + + @Mock + private PostService postService; + + @Mock + private CommentService commentService; + @Mock private UserRepository userRepository; @@ -133,6 +164,10 @@ class AdminServiceTest { private static final Long INQUIRY_ID = 1L; private static final Long TARGET_ID = 100L; private static final Long ARTIST_ID = 20L; + private static final Long NOTICE_ID = 1L; + private static final Long ADMIN_ID = 5L; + private static final Long REPORT_ID = 1L; + private static final Long REPORTER_ID = 30L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); private static final LocalDateTime CREATED_AT = LocalDateTime.of(2025, 8, 20, 0, 0); @@ -162,6 +197,32 @@ private User buildUser(Long id, String nickname) { .build(); } + private Notice buildNotice(Long id, String title, String content, boolean active) { + Notice notice = Notice.builder() + .id(id) + .userId(ADMIN_ID) + .title(title) + .content(content) + .active(active) + .build(); + ReflectionTestUtils.setField(notice, "createdAt", CREATED_AT); + return notice; + } + + private Report buildReport(ReportTargetType targetType, Long targetId, ReportStatus status) { + Report report = Report.builder() + .id(REPORT_ID) + .reporterId(REPORTER_ID) + .targetType(targetType) + .targetId(targetId) + .reason(ReportReason.SPAM) + .detail("광고성 게시글입니다.") + .status(status) + .build(); + ReflectionTestUtils.setField(report, "createdAt", CREATED_AT); + return report; + } + @Test void should_update_all_fields_when_full_update_request_given() { // given @@ -1918,4 +1979,387 @@ void should_return_empty_page_when_no_local_artists_match() { assertThat(response.content()).isEmpty(); assertThat(response.totalElements()).isZero(); } + + // ------------------------------------------------------------------------- + // getNotices + // ------------------------------------------------------------------------- + + @Test + void should_return_paginated_notices_when_notices_exist() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + Page page = new PageImpl<>(List.of(notice), PAGEABLE, 1); + given(noticeRepository.findAll(PAGEABLE)).willReturn(page); + + // when + PageResponse response = adminService.getNotices(PAGEABLE); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).id()).isEqualTo(NOTICE_ID); + assertThat(response.content().get(0).title()).isEqualTo("점검 안내"); + assertThat(response.content().get(0).active()).isTrue(); + } + + // ------------------------------------------------------------------------- + // getAdminNotice + // ------------------------------------------------------------------------- + + @Test + void should_return_notice_detail_when_valid_id_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + AdminNoticeDetailResponse response = adminService.getAdminNotice(NOTICE_ID); + + // then + assertThat(response.id()).isEqualTo(NOTICE_ID); + assertThat(response.title()).isEqualTo("점검 안내"); + assertThat(response.content()).isEqualTo("9월 20일 점검이 진행됩니다."); + assertThat(response.active()).isTrue(); + } + + @Test + void should_return_inactive_notice_when_notice_is_deactivated() { + // given + Notice notice = buildNotice(NOTICE_ID, "종료된 공지", "이벤트가 종료되었습니다.", false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + AdminNoticeDetailResponse response = adminService.getAdminNotice(NOTICE_ID); + + // then + assertThat(response.active()).isFalse(); + } + + @Test + void should_throw_notice_not_found_when_get_admin_notice_with_invalid_id() { + // given + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.getAdminNotice(999L)) + .isInstanceOf(NoticeNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // createNotice + // ------------------------------------------------------------------------- + + @Test + void should_create_active_notice_when_active_is_null() { + // given + AdminNoticeCreateRequest request = new AdminNoticeCreateRequest("점검 안내", "9월 20일 점검이 진행됩니다.", null); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminService.createNotice(ADMIN_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); + verify(noticeRepository).save(captor.capture()); + assertThat(captor.getValue().isActive()).isTrue(); + assertThat(captor.getValue().getUserId()).isEqualTo(ADMIN_ID); + } + + @Test + void should_create_inactive_notice_when_active_is_false() { + // given + AdminNoticeCreateRequest request = new AdminNoticeCreateRequest("임시 저장 공지", "아직 게시하지 않습니다.", false); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminService.createNotice(ADMIN_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); + verify(noticeRepository).save(captor.capture()); + assertThat(captor.getValue().isActive()).isFalse(); + } + + // ------------------------------------------------------------------------- + // updateNotice + // ------------------------------------------------------------------------- + + @Test + void should_update_title_and_content_when_update_request_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("점검 안내 (수정)", "9월 21일로 연기되었습니다.", null); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.getTitle()).isEqualTo("점검 안내 (수정)"); + assertThat(notice.getContent()).isEqualTo("9월 21일로 연기되었습니다."); + } + + @Test + void should_activate_notice_when_active_true_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", false); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest(null, null, true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isTrue(); + } + + @Test + void should_deactivate_notice_when_active_false_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest(null, null, false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isFalse(); + } + + @Test + void should_not_change_active_status_when_active_is_null() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("점검 안내 (수정)", null, null); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.updateNotice(NOTICE_ID, request); + + // then + assertThat(notice.isActive()).isTrue(); + } + + @Test + void should_throw_notice_not_found_when_update_target_does_not_exist() { + // given + AdminNoticeUpdateRequest request = new AdminNoticeUpdateRequest("제목", null, null); + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.updateNotice(999L, request)) + .isInstanceOf(NoticeNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // deleteNotice + // ------------------------------------------------------------------------- + + @Test + void should_delete_notice_when_valid_id_given() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "9월 20일 점검이 진행됩니다.", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + adminService.deleteNotice(NOTICE_ID); + + // then + verify(noticeRepository).delete(notice); + } + + @Test + void should_throw_notice_not_found_when_delete_target_does_not_exist() { + // given + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.deleteNotice(999L)) + .isInstanceOf(NoticeNotFoundException.class); + verify(noticeRepository, never()).delete(any()); + } + + // ------------------------------------------------------------------------- + // getReports + // ------------------------------------------------------------------------- + + @Test + void should_call_find_all_when_target_type_and_status_are_null() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAll(PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); + + // when + PageResponse response = adminService.getReports(null, null, PAGEABLE); + + // then + verify(reportRepository).findAll(PAGEABLE); + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).reporterNickname()).isEqualTo("신고자닉네임"); + } + + @Test + void should_call_find_all_by_target_type_when_only_target_type_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByTargetType(ReportTargetType.POST, PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); + + // when + PageResponse response = adminService.getReports(ReportTargetType.POST, null, PAGEABLE); + + // then + verify(reportRepository).findAllByTargetType(ReportTargetType.POST, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + @Test + void should_call_find_all_by_status_when_only_status_given_for_reports() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByStatus(ReportStatus.PENDING, PAGEABLE)).willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); + + // when + PageResponse response = adminService.getReports(null, ReportStatus.PENDING, PAGEABLE); + + // then + verify(reportRepository).findAllByStatus(ReportStatus.PENDING, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + @Test + void should_call_find_all_by_target_type_and_status_when_both_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + Page page = new PageImpl<>(List.of(report), PAGEABLE, 1); + given(reportRepository.findAllByTargetTypeAndStatus(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE)) + .willReturn(page); + given(userRepository.findAllByIdIn(List.of(REPORTER_ID))).willReturn(List.of(buildUser(REPORTER_ID, "신고자닉네임"))); + + // when + PageResponse response = + adminService.getReports(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE); + + // then + verify(reportRepository).findAllByTargetTypeAndStatus(ReportTargetType.POST, ReportStatus.PENDING, PAGEABLE); + assertThat(response.content()).hasSize(1); + } + + // ------------------------------------------------------------------------- + // getReportDetail + // ------------------------------------------------------------------------- + + @Test + void should_return_report_detail_when_valid_id_given() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + given(userRepository.findById(REPORTER_ID)).willReturn(Optional.of(buildUser(REPORTER_ID, "신고자닉네임"))); + + // when + AdminReportDetailResponse response = adminService.getReportDetail(REPORT_ID); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + assertThat(response.targetType()).isEqualTo(ReportTargetType.POST); + assertThat(response.targetId()).isEqualTo(TARGET_ID); + assertThat(response.reason()).isEqualTo(ReportReason.SPAM); + assertThat(response.detail()).isEqualTo("광고성 게시글입니다."); + assertThat(response.status()).isEqualTo(ReportStatus.PENDING); + assertThat(response.reporterId()).isEqualTo(REPORTER_ID); + assertThat(response.reporterNickname()).isEqualTo("신고자닉네임"); + } + + @Test + void should_throw_report_not_found_when_report_id_does_not_exist() { + // given + given(reportRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.getReportDetail(999L)) + .isInstanceOf(ReportNotFoundException.class); + } + + // ------------------------------------------------------------------------- + // updateReportStatus + // ------------------------------------------------------------------------- + + @Test + void should_update_status_without_deleting_target_when_delete_target_is_null() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "처리 완료", null); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + assertThat(report.getStatus()).isEqualTo(ReportStatus.RESOLVED); + assertThat(report.getAdminNote()).isEqualTo("처리 완료"); + verify(postService, never()).adminDelete(any()); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_not_delete_target_when_delete_target_is_false() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.REJECTED, "정상 게시글입니다.", false); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + assertThat(report.getStatus()).isEqualTo(ReportStatus.REJECTED); + verify(postService, never()).adminDelete(any()); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_delete_post_when_delete_target_true_and_target_type_post() { + // given + Report report = buildReport(ReportTargetType.POST, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + verify(postService).adminDelete(TARGET_ID); + verify(commentService, never()).adminDelete(any()); + } + + @Test + void should_delete_comment_when_delete_target_true_and_target_type_comment() { + // given + Report report = buildReport(ReportTargetType.COMMENT, TARGET_ID, ReportStatus.PENDING); + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, "삭제 처리", true); + given(reportRepository.findById(REPORT_ID)).willReturn(Optional.of(report)); + + // when + adminService.updateReportStatus(REPORT_ID, request); + + // then + verify(commentService).adminDelete(TARGET_ID); + verify(postService, never()).adminDelete(any()); + } + + @Test + void should_throw_report_not_found_when_update_target_does_not_exist() { + // given + AdminReportStatusUpdateRequest request = new AdminReportStatusUpdateRequest(ReportStatus.RESOLVED, null, null); + given(reportRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminService.updateReportStatus(999L, request)) + .isInstanceOf(ReportNotFoundException.class); + } } diff --git a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 36b8545..6bc59af 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -4,8 +4,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -17,8 +20,14 @@ import com.Coming.Backend.concert.entity.ConcertStatus; import com.Coming.Backend.concert.exception.ConcertNotFoundException; import com.Coming.Backend.concert.service.ConcertService; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.service.RatingService; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,6 +37,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -40,10 +53,14 @@ class ConcertControllerTest { @Mock private ConcertService concertService; + @Mock + private RatingService ratingService; + @InjectMocks private ConcertController concertController; private static final Long CONCERT_ID = 1L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { @@ -51,11 +68,16 @@ void setUp() { validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(concertController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver(), new AuthenticationPrincipalArgumentResolver()) .setValidator(validator) .build(); } + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + private ConcertSummaryResponse buildSummary(Long id, String title, String artistName) { List artists = artistName != null ? List.of(new ArtistSummary(1L, artistName, null)) @@ -70,7 +92,9 @@ private ConcertSummaryResponse buildSummary(Long id, String title, String artist "올림픽공원", ConcertStatus.UPCOMING, false, - null + null, + null, + 0 ); } @@ -194,4 +218,81 @@ void should_return_401_with_error_body_when_following_true_and_user_not_authenti .andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // PUT /api/concerts/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_score_given() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", CONCERT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isOk()); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); + } + + @Test + void should_return_400_when_score_is_out_of_range() throws Exception { + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", CONCERT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 5.5}")) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_rating_target_does_not_exist() throws Exception { + // given + willThrow(new RatingTargetNotFoundException()) + .given(ratingService).upsert(isNull(), eq(RatingTargetType.CONCERT), eq(999L), any(BigDecimal.class)); + + // when & then + mockMvc.perform(put("/api/concerts/{id}/rating", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RATING_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/concerts/{id}/rating/me + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_my_score_when_rating_exists() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + given(ratingService.getMine(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID))) + .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); + + // when & then + mockMvc.perform(get("/api/concerts/{id}/rating/me", CONCERT_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(4.5)); + } + + // ------------------------------------------------------------------------- + // DELETE /api/concerts/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_rating_deleted() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + // when & then + mockMvc.perform(delete("/api/concerts/{id}/rating", CONCERT_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); + } } diff --git a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java index fba15e2..97ca2ae 100644 --- a/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java +++ b/src/test/java/com/Coming/Backend/concert/service/ConcertServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -38,12 +39,17 @@ import com.Coming.Backend.concert.repository.ConcertBookingLinkRepository; import com.Coming.Backend.concert.repository.ConcertImageRepository; import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -90,11 +96,21 @@ class ConcertServiceTest { @Mock private SetlistTrackRepository setlistTrackRepository; + @Mock + private RatingService ratingService; + private static final Long CONCERT_ID = 1L; private static final Long ARTIST_ID = 10L; private static final Long USER_ID = 100L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); + @BeforeEach + void setUp() { + // 평점 집계는 대부분의 테스트와 무관하므로 기본값(빈 맵)을 lenient로 스텁한다. + // 평점 매핑을 직접 검증하는 테스트는 개별적으로 given()을 재정의한다. + lenient().when(ratingService.getSummaries(any(), any())).thenReturn(Map.of()); + } + private Concert buildConcert(Long id, ConcertStatus status) { return Concert.builder() .id(id) @@ -223,6 +239,28 @@ void should_return_is_in_calendar_true_for_popular_concerts_when_authenticated_u assertThat(result.get(0).isInCalendar()).isTrue(); } + @Test + void should_map_rating_summary_per_concert_and_use_default_when_only_some_concerts_have_ratings() { + // given + Concert rated = buildConcert(1L, ConcertStatus.UPCOMING); + Concert unrated = buildConcert(2L, ConcertStatus.UPCOMING); + RatingSummary ratingSummary = new RatingSummary(4.0, 5L); + + given(concertRepository.findTop10Popular(anyList())).willReturn(List.of(rated, unrated)); + given(concertArtistRepository.findByConcertIdIn(List.of(1L, 2L))).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(1L, 2L))) + .willReturn(Map.of(1L, ratingSummary)); + + // when + List result = concertService.getPopularConcerts(null); + + // then + assertThat(result.get(0).averageRating()).isEqualTo(4.0); + assertThat(result.get(0).ratingCount()).isEqualTo(5L); + assertThat(result.get(1).averageRating()).isNull(); + assertThat(result.get(1).ratingCount()).isZero(); + } + @Test void should_not_include_excluded_concerts_in_popular_list() { // given @@ -654,6 +692,49 @@ void should_throw_concert_not_found_when_concert_is_excluded() { .hasMessage(ErrorCode.CONCERT_NOT_FOUND.getMessage()); } + // ------------------------------------------------------------------------- + // getConcert — 평점 집계 매핑 + // ------------------------------------------------------------------------- + + @Test + void should_return_rating_summary_when_concert_has_ratings() { + // given + Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); + RatingSummary ratingSummary = new RatingSummary(4.5, 12L); + + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + given(concertArtistRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(concertBookingLinkRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(CONCERT_ID))) + .willReturn(Map.of(CONCERT_ID, ratingSummary)); + + // when + ConcertDetailResponse response = concertService.getConcert(CONCERT_ID, null); + + // then + assertThat(response.averageRating()).isEqualTo(4.5); + assertThat(response.ratingCount()).isEqualTo(12L); + } + + @Test + void should_return_null_average_rating_and_zero_rating_count_when_concert_has_no_ratings() { + // given + Concert concert = buildConcert(CONCERT_ID, ConcertStatus.UPCOMING); + + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + given(concertArtistRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(concertBookingLinkRepository.findByConcertId(CONCERT_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.CONCERT, List.of(CONCERT_ID))) + .willReturn(Map.of()); + + // when + ConcertDetailResponse response = concertService.getConcert(CONCERT_ID, null); + + // then + assertThat(response.averageRating()).isNull(); + assertThat(response.ratingCount()).isZero(); + } + // ------------------------------------------------------------------------- // getSetlist // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java b/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java new file mode 100644 index 0000000..455cc8d --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/controller/NoticeControllerTest.java @@ -0,0 +1,103 @@ +package com.Coming.Backend.notice.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.service.NoticeService; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class NoticeControllerTest { + + private MockMvc mockMvc; + + @Mock + private NoticeService noticeService; + + @InjectMocks + private NoticeController noticeController; + + private static final Long NOTICE_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(noticeController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + } + + // ------------------------------------------------------------------------- + // GET /api/notices + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_list_when_default_params_given() throws Exception { + // given + NoticeSummaryResponse summary = new NoticeSummaryResponse(NOTICE_ID, "점검 안내", LocalDateTime.now()); + given(noticeService.getRecent(eq(5))).willReturn(List.of(summary)); + + // when & then + mockMvc.perform(get("/api/notices").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(NOTICE_ID)) + .andExpect(jsonPath("$[0].title").value("점검 안내")); + } + + // ------------------------------------------------------------------------- + // GET /api/notices/{id} + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_notice_detail_when_notice_exists() throws Exception { + // given + NoticeDetailResponse detail = new NoticeDetailResponse( + NOTICE_ID, "점검 안내", "점검은 새벽 2시부터 진행됩니다", LocalDateTime.now(), LocalDateTime.now()); + given(noticeService.getDetail(eq(NOTICE_ID))).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/notices/{id}", NOTICE_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(NOTICE_ID)) + .andExpect(jsonPath("$.title").value("점검 안내")); + } + + @Test + void should_return_404_when_notice_not_found_on_get_detail() throws Exception { + // given + given(noticeService.getDetail(eq(999L))).willThrow(new NoticeNotFoundException()); + + // when & then + mockMvc.perform(get("/api/notices/{id}", 999L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.NOTICE_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java b/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java new file mode 100644 index 0000000..d46b74b --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/repository/NoticeRepositoryTest.java @@ -0,0 +1,69 @@ +package com.Coming.Backend.notice.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.notice.entity.Notice; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.data.domain.PageRequest; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class NoticeRepositoryTest { + + @Autowired + private NoticeRepository noticeRepository; + + private static final Long AUTHOR_ID = 1L; + + private Notice buildNotice(String title, boolean active) { + return Notice.builder() + .userId(AUTHOR_ID) + .title(title) + .content("공지 내용") + .active(active) + .build(); + } + + @Test + void should_return_active_notice_when_finding_active_notices() { + // given + Notice activeNotice = noticeRepository.save(buildNotice("점검 안내", true)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId).contains(activeNotice.getId()); + } + + @Test + void should_exclude_inactive_notice_when_finding_active_notices() { + // given + Notice inactiveNotice = noticeRepository.save(buildNotice("종료된 공지", false)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId).doesNotContain(inactiveNotice.getId()); + } + + @Test + void should_return_active_notices_ordered_by_created_at_desc() throws InterruptedException { + // given + Notice olderNotice = noticeRepository.save(buildNotice("먼저 등록된 공지", true)); + Thread.sleep(10); + Notice newerNotice = noticeRepository.save(buildNotice("나중에 등록된 공지", true)); + + // when + var result = noticeRepository.findActiveNotices(PageRequest.of(0, 20)); + + // then + assertThat(result).extracting(Notice::getId) + .containsSubsequence(newerNotice.getId(), olderNotice.getId()); + } +} diff --git a/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java b/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java new file mode 100644 index 0000000..b837ad9 --- /dev/null +++ b/src/test/java/com/Coming/Backend/notice/service/NoticeServiceTest.java @@ -0,0 +1,120 @@ +package com.Coming.Backend.notice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.notice.dto.NoticeDetailResponse; +import com.Coming.Backend.notice.dto.NoticeSummaryResponse; +import com.Coming.Backend.notice.entity.Notice; +import com.Coming.Backend.notice.exception.NoticeNotFoundException; +import com.Coming.Backend.notice.repository.NoticeRepository; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceTest { + + @InjectMocks + private NoticeService noticeService; + + @Mock + private NoticeRepository noticeRepository; + + private static final Long AUTHOR_ID = 1L; + private static final Long NOTICE_ID = 10L; + + private Notice buildNotice(Long id, String title, String content, boolean active) { + Notice notice = Notice.builder() + .userId(AUTHOR_ID) + .title(title) + .content(content) + .active(active) + .build(); + ReflectionTestUtils.setField(notice, "id", id); + return notice; + } + + // ------------------------------------------------------------------------- + // getRecent + // ------------------------------------------------------------------------- + + @Test + void should_return_summary_responses_when_active_notices_found() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "내용", true); + given(noticeRepository.findActiveNotices(PageRequest.of(0, 5))).willReturn(List.of(notice)); + + // when + List response = noticeService.getRecent(5); + + // then + assertThat(response).hasSize(1); + assertThat(response.get(0).id()).isEqualTo(NOTICE_ID); + assertThat(response.get(0).title()).isEqualTo("점검 안내"); + } + + @Test + void should_return_empty_list_when_no_active_notices_found() { + // given + given(noticeRepository.findActiveNotices(PageRequest.of(0, 5))).willReturn(List.of()); + + // when + List response = noticeService.getRecent(5); + + // then + assertThat(response).isEmpty(); + } + + // ------------------------------------------------------------------------- + // getDetail + // ------------------------------------------------------------------------- + + @Test + void should_throw_notice_not_found_exception_when_notice_does_not_exist() { + // given + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> noticeService.getDetail(NOTICE_ID)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessage(ErrorCode.NOTICE_NOT_FOUND.getMessage()); + } + + @Test + void should_return_notice_detail_when_active_notice_found() { + // given + Notice notice = buildNotice(NOTICE_ID, "점검 안내", "점검은 새벽 2시부터 진행됩니다", true); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when + NoticeDetailResponse response = noticeService.getDetail(NOTICE_ID); + + // then + assertThat(response.id()).isEqualTo(NOTICE_ID); + assertThat(response.title()).isEqualTo("점검 안내"); + assertThat(response.content()).isEqualTo("점검은 새벽 2시부터 진행됩니다"); + } + + @Test + void should_throw_notice_not_found_exception_when_notice_exists_but_inactive() { + // given + Notice notice = buildNotice(NOTICE_ID, "종료된 공지", "내용", false); + given(noticeRepository.findById(NOTICE_ID)).willReturn(Optional.of(notice)); + + // when & then + assertThatThrownBy(() -> noticeService.getDetail(NOTICE_ID)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessage(ErrorCode.NOTICE_NOT_FOUND.getMessage()); + } +} diff --git a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java index 615eecd..bddb331 100644 --- a/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java +++ b/src/test/java/com/Coming/Backend/post/controller/PostControllerTest.java @@ -255,6 +255,28 @@ void should_return_200_with_popular_posts_when_default_params_given() throws Exc .andExpect(jsonPath("$[0].title").value("인기 게시글")); } + // ------------------------------------------------------------------------- + // GET /api/posts/popular-board + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_page_response_when_getting_popular_board_with_default_params() throws Exception { + // given + PostSummaryResponse summary = new PostSummaryResponse( + POST_ID, "IU", PostCategory.FREE, "인기글", List.of(), 10L, 0L, LocalDateTime.now()); + PageResponse pageResponse = new PageResponse<>(List.of(summary), 0, 20, 1, 1); + given(postService.getPopularBoard(eq(0), eq(20))).willReturn(pageResponse); + + // when & then + mockMvc.perform(get("/api/posts/popular-board").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(POST_ID)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + // ------------------------------------------------------------------------- // GET /api/posts/trending-tags // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java index 26ec90d..978307c 100644 --- a/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/post/repository/PostRepositoryTest.java @@ -57,6 +57,19 @@ private Post buildPost(String title, String contentText) { .build(); } + private Post buildPostWithRecommendCount(String title, long recommendCount) { + return Post.builder() + .userId(AUTHOR_ID) + .category(PostCategory.FREE) + .title(title) + .content("{\"type\":\"doc\"}") + .contentText("내용") + .recommendCount(recommendCount) + .viewCount(0L) + .commentCount(0L) + .build(); + } + @Test void should_return_post_when_title_matches_search_query() { // given @@ -219,4 +232,47 @@ void should_return_posts_ordered_by_created_at_desc_when_multiple_posts_match() assertThat(result.getContent()).extracting(Post::getId) .containsSubsequence(newerPost.getId(), olderPost.getId()); } + + // ------------------------------------------------------------------------- + // findPopularBoard + // ------------------------------------------------------------------------- + + @Test + void should_return_post_when_recommend_count_is_at_or_above_threshold() { + // given + Post qualifyingPost = postRepository.save(buildPostWithRecommendCount("추천수 딱 임계치", 10L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).contains(qualifyingPost.getId()); + } + + @Test + void should_exclude_post_when_recommend_count_is_below_threshold() { + // given + Post belowThresholdPost = postRepository.save(buildPostWithRecommendCount("추천수 미달", 9L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId).doesNotContain(belowThresholdPost.getId()); + } + + @Test + void should_return_posts_ordered_by_created_at_desc_when_finding_popular_board() throws InterruptedException { + // given + Post olderPost = postRepository.save(buildPostWithRecommendCount("먼저 쓴 인기글", 10L)); + Thread.sleep(10); + Post newerPost = postRepository.save(buildPostWithRecommendCount("나중에 쓴 인기글", 20L)); + + // when + Page result = postRepository.findPopularBoard(10L, PageRequest.of(0, 20)); + + // then + assertThat(result.getContent()).extracting(Post::getId) + .containsSubsequence(newerPost.getId(), olderPost.getId()); + } } diff --git a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java index 240a29c..83b6d6a 100644 --- a/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/CommentServiceTest.java @@ -323,6 +323,35 @@ void should_soft_delete_comment_when_author_deletes() { verify(commentRepository, never()).delete(any(Comment.class)); } + // ------------------------------------------------------------------------- + // adminDelete + // ------------------------------------------------------------------------- + + @Test + void should_throw_comment_not_found_exception_when_comment_does_not_exist_on_admin_delete() { + // given + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.adminDelete(COMMENT_ID)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + void should_soft_delete_comment_when_admin_deletes_comment_authored_by_another_user() { + // given + Comment comment = buildComment(COMMENT_ID, POST_ID, AUTHOR_ID, null, "댓글", 0L, false); + given(commentRepository.findById(COMMENT_ID)).willReturn(Optional.of(comment)); + + // when + commentService.adminDelete(COMMENT_ID); + + // then + assertThat(comment.isDeleted()).isTrue(); + verify(commentRepository, never()).delete(any(Comment.class)); + } + // ------------------------------------------------------------------------- // like // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java index 18cc419..19124d1 100644 --- a/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java +++ b/src/test/java/com/Coming/Backend/post/service/PostServiceTest.java @@ -576,6 +576,45 @@ void should_return_empty_list_when_no_popular_posts_found() { verify(postEntityTagRepository, never()).findByPostIdIn(any()); } + // ------------------------------------------------------------------------- + // getPopularBoard + // ------------------------------------------------------------------------- + + @Test + void should_return_page_response_when_popular_board_posts_found() { + // given + ReflectionTestUtils.setField(postService, "popularBoardThreshold", 10L); + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "인기글", 3L); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(post), pageable, 1); + given(postRepository.findPopularBoard(10L, pageable)).willReturn(page); + given(postEntityTagRepository.findByPostIdIn(List.of(POST_ID))).willReturn(List.of()); + given(userRepository.findAllByIdIn(Set.of(AUTHOR_ID))).willReturn(List.of(buildUser(AUTHOR_ID, "IU"))); + + // when + PageResponse response = postService.getPopularBoard(0, 20); + + // then + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).title()).isEqualTo("인기글"); + verify(postRepository).findPopularBoard(10L, pageable); + } + + @Test + void should_return_empty_page_response_when_no_popular_board_posts_found() { + // given + ReflectionTestUtils.setField(postService, "popularBoardThreshold", 10L); + Pageable pageable = PageRequest.of(0, 20); + given(postRepository.findPopularBoard(10L, pageable)).willReturn(Page.empty(pageable)); + + // when + PageResponse response = postService.getPopularBoard(0, 20); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isZero(); + } + // ------------------------------------------------------------------------- // getTrendingTags // ------------------------------------------------------------------------- @@ -898,6 +937,38 @@ void should_delete_comments_comment_likes_and_recommends_when_author_deletes_pos verify(postRecommendRepository).deleteByPostId(POST_ID); } + // ------------------------------------------------------------------------- + // adminDelete + // ------------------------------------------------------------------------- + + @Test + void should_throw_post_not_found_exception_when_admin_deleting_post_that_does_not_exist() { + // given + given(postRepository.findById(POST_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.adminDelete(POST_ID)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + void should_delete_post_and_dependents_when_admin_deletes_post_authored_by_another_user() { + // given + Post post = buildPost(POST_ID, AUTHOR_ID, PostCategory.FREE, "제목", 0L); + given(postRepository.findById(POST_ID)).willReturn(Optional.of(post)); + + // when + postService.adminDelete(POST_ID); + + // then + verify(commentLikeRepository).deleteByCommentPostId(POST_ID); + verify(commentRepository).deleteByPostId(POST_ID); + verify(postRecommendRepository).deleteByPostId(POST_ID); + verify(postEntityTagRepository).deleteByPostId(POST_ID); + verify(postRepository).delete(post); + } + // ------------------------------------------------------------------------- // recommend // ------------------------------------------------------------------------- diff --git a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java new file mode 100644 index 0000000..edb5e73 --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java @@ -0,0 +1,200 @@ +package com.Coming.Backend.rating.repository; + +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.repository.RatingRepository.RatingAggregate; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class RatingRepositoryTest { + + @Autowired + private RatingRepository ratingRepository; + + @Autowired + private TestEntityManager entityManager; + + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 2L; + private static final Long TARGET_ID = 10L; + private static final Long OTHER_TARGET_ID = 20L; + private static final Long UNRATED_TARGET_ID = 30L; + + private Rating buildRating(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { + return Rating.builder() + .userId(userId) + .targetType(targetType) + .targetId(targetId) + .score(score) + .build(); + } + + @Test + void should_return_rating_when_matching_combination_exists() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(4)); + } + + @Test + void should_return_empty_when_user_id_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + OTHER_USER_ID, RatingTargetType.CONCERT, TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_return_empty_when_target_type_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.RELEASE, TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_return_empty_when_target_id_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, OTHER_TARGET_ID); + + // then + assertThat(found).isEmpty(); + } + + @Test + void should_aggregate_average_and_count_when_multiple_users_rate_same_target() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + ratingRepository.save(buildRating(OTHER_USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(5))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID)); + + // then + assertThat(aggregates).hasSize(1); + assertThat(aggregates.get(0).getTargetId()).isEqualTo(TARGET_ID); + assertThat(aggregates.get(0).getAverageScore()).isEqualTo(4.5); + assertThat(aggregates.get(0).getRatingCount()).isEqualTo(2L); + } + + @Test + void should_aggregate_independently_when_multiple_target_ids_given() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, OTHER_TARGET_ID, BigDecimal.valueOf(2))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID, OTHER_TARGET_ID)); + + // then + assertThat(aggregates).hasSize(2); + assertThat(aggregates) + .filteredOn(aggregate -> aggregate.getTargetId().equals(TARGET_ID)) + .extracting(RatingAggregate::getAverageScore) + .containsExactly(4.0); + assertThat(aggregates) + .filteredOn(aggregate -> aggregate.getTargetId().equals(OTHER_TARGET_ID)) + .extracting(RatingAggregate::getAverageScore) + .containsExactly(2.0); + } + + @Test + void should_exclude_rating_from_aggregate_when_target_type_differs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.RELEASE, TARGET_ID, BigDecimal.valueOf(5))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID)); + + // then + assertThat(aggregates).isEmpty(); + } + + @Test + void should_exclude_target_id_from_result_when_no_ratings_exist() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4))); + + // when + List aggregates = ratingRepository.aggregateByTargetIds( + RatingTargetType.CONCERT, List.of(TARGET_ID, UNRATED_TARGET_ID)); + + // then + assertThat(aggregates) + .extracting(RatingAggregate::getTargetId) + .containsExactly(TARGET_ID); + } + + // ------------------------------------------------------------------------- + // upsert + // ------------------------------------------------------------------------- + + @Test + void should_insert_new_rating_when_no_existing_rating() { + // given & when + ratingRepository.upsert(USER_ID, RatingTargetType.CONCERT.name(), TARGET_ID, BigDecimal.valueOf(4.0)); + entityManager.clear(); + + // then + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(4.0)); + } + + @Test + void should_update_existing_rating_when_conflict_occurs() { + // given + ratingRepository.save(buildRating(USER_ID, RatingTargetType.CONCERT, TARGET_ID, BigDecimal.valueOf(4.0))); + entityManager.clear(); + + // when + ratingRepository.upsert(USER_ID, RatingTargetType.CONCERT.name(), TARGET_ID, BigDecimal.valueOf(2.5)); + entityManager.clear(); + + // then + Optional found = ratingRepository.findByUserIdAndTargetTypeAndTargetId( + USER_ID, RatingTargetType.CONCERT, TARGET_ID); + assertThat(found).isPresent(); + assertThat(found.get().getScore()).isEqualByComparingTo(BigDecimal.valueOf(2.5)); + assertThat(ratingRepository.findAll()) + .filteredOn(rating -> rating.getUserId().equals(USER_ID) + && rating.getTargetType() == RatingTargetType.CONCERT + && rating.getTargetId().equals(TARGET_ID)) + .hasSize(1); + } +} diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java new file mode 100644 index 0000000..0b0a8e8 --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -0,0 +1,282 @@ +package com.Coming.Backend.rating.service; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.concert.entity.Concert; +import com.Coming.Backend.concert.entity.ConcertStatus; +import com.Coming.Backend.concert.exception.ConcertNotEndedException; +import com.Coming.Backend.concert.exception.UnauthorizedException; +import com.Coming.Backend.concert.repository.ConcertRepository; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.Rating; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.InvalidRatingScoreException; +import com.Coming.Backend.rating.exception.RatingNotFoundException; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.repository.RatingRepository; +import com.Coming.Backend.release.repository.ReleaseGroupRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class RatingServiceTest { + + @InjectMocks + private RatingService ratingService; + + @Mock + private RatingRepository ratingRepository; + + @Mock + private ConcertRepository concertRepository; + + @Mock + private ReleaseGroupRepository releaseGroupRepository; + + private static final Long USER_ID = 1L; + private static final Long CONCERT_ID = 10L; + + // ------------------------------------------------------------------------- + // upsert + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_upsert() { + // when & then + assertThatThrownBy(() -> ratingService.upsert(null, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(4.3); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + + @Test + void should_throw_invalid_rating_score_exception_when_score_is_below_min() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(0.0); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + + @Test + void should_throw_invalid_rating_score_exception_when_score_is_above_max() { + // given + BigDecimal invalidScore = BigDecimal.valueOf(5.5); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, invalidScore)) + .isInstanceOf(InvalidRatingScoreException.class) + .hasMessage(ErrorCode.INVALID_RATING_SCORE.getMessage()); + } + + @Test + void should_throw_rating_target_not_found_exception_when_concert_does_not_exist() { + // given + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(RatingTargetNotFoundException.class) + .hasMessage(ErrorCode.RATING_TARGET_NOT_FOUND.getMessage()); + } + + @Test + void should_throw_concert_not_ended_exception_when_concert_status_is_not_ended() { + // given + Concert concert = Concert.builder() + .status(ConcertStatus.UPCOMING) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + + // when & then + assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) + .isInstanceOf(ConcertNotEndedException.class) + .hasMessage(ErrorCode.CONCERT_NOT_ENDED.getMessage()); + } + + @Test + void should_call_repository_upsert_when_concert_is_ended() { + // given + BigDecimal score = BigDecimal.valueOf(4.5); + Concert concert = Concert.builder() + .status(ConcertStatus.ENDED) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); + + // when + ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, score); + + // then + verify(ratingRepository).upsert(USER_ID, "CONCERT", CONCERT_ID, score); + } + + @Test + void should_call_repository_upsert_when_release_target_exists() { + // given + Long releaseId = 20L; + BigDecimal score = BigDecimal.valueOf(4.0); + given(releaseGroupRepository.existsById(releaseId)).willReturn(true); + + // when + ratingService.upsert(USER_ID, RatingTargetType.RELEASE, releaseId, score); + + // then + verify(ratingRepository).upsert(USER_ID, "RELEASE", releaseId, score); + } + + // ------------------------------------------------------------------------- + // getMine + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_get_mine() { + // when & then + assertThatThrownBy(() -> ratingService.getMine(null, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_return_score_when_rating_exists() { + // given + Rating rating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(4.5)) + .build(); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(rating)); + + // when + RatingMeResponse response = ratingService.getMine(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + assertThat(response.score()).isEqualByComparingTo(BigDecimal.valueOf(4.5)); + } + + @Test + void should_return_null_score_when_rating_does_not_exist() { + // given + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when + RatingMeResponse response = ratingService.getMine(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + assertThat(response.score()).isNull(); + } + + // ------------------------------------------------------------------------- + // delete + // ------------------------------------------------------------------------- + + @Test + void should_throw_unauthorized_exception_when_user_id_is_null_on_delete() { + // when & then + assertThatThrownBy(() -> ratingService.delete(null, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.UNAUTHORIZED.getMessage()); + } + + @Test + void should_throw_rating_not_found_exception_when_rating_does_not_exist() { + // given + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> ratingService.delete(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .isInstanceOf(RatingNotFoundException.class) + .hasMessage(ErrorCode.RATING_NOT_FOUND.getMessage()); + } + + @Test + void should_delete_rating_when_rating_exists() { + // given + Rating rating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(4.5)) + .build(); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(rating)); + + // when + ratingService.delete(USER_ID, RatingTargetType.CONCERT, CONCERT_ID); + + // then + verify(ratingRepository).delete(rating); + } + + // ------------------------------------------------------------------------- + // getSummaries + // ------------------------------------------------------------------------- + + @Test + void should_return_empty_map_when_target_ids_is_empty() { + // when + Map summaries = ratingService.getSummaries(RatingTargetType.CONCERT, List.of()); + + // then + assertThat(summaries).isEmpty(); + verifyNoInteractions(ratingRepository); + } + + @Test + void should_return_summary_map_when_aggregates_exist() { + // given + RatingRepository.RatingAggregate aggregate1 = mock(RatingRepository.RatingAggregate.class); + given(aggregate1.getTargetId()).willReturn(1L); + given(aggregate1.getAverageScore()).willReturn(4.33); + given(aggregate1.getRatingCount()).willReturn(3L); + + RatingRepository.RatingAggregate aggregate2 = mock(RatingRepository.RatingAggregate.class); + given(aggregate2.getTargetId()).willReturn(2L); + given(aggregate2.getAverageScore()).willReturn(5.0); + given(aggregate2.getRatingCount()).willReturn(1L); + + given(ratingRepository.aggregateByTargetIds(any(RatingTargetType.class), anyCollection())) + .willReturn(List.of(aggregate1, aggregate2)); + + // when + Map summaries = ratingService.getSummaries(RatingTargetType.CONCERT, List.of(1L, 2L)); + + // then + assertThat(summaries.get(1L).averageRating()).isEqualTo(4.3); + assertThat(summaries.get(1L).ratingCount()).isEqualTo(3L); + assertThat(summaries.get(2L).averageRating()).isEqualTo(5.0); + assertThat(summaries.get(2L).ratingCount()).isEqualTo(1L); + } +} diff --git a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java index 754c32d..3e05ce1 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -4,7 +4,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -12,13 +16,19 @@ import com.Coming.Backend.common.exception.GlobalExceptionHandler; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingMeResponse; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.exception.RatingTargetNotFoundException; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; import com.Coming.Backend.release.dto.TrackDto; import com.Coming.Backend.release.exception.ReleaseNotFoundException; import com.Coming.Backend.release.service.ReleaseService; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,8 +38,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @ExtendWith(MockitoExtension.class) class ReleaseControllerTest { @@ -39,20 +54,32 @@ class ReleaseControllerTest { @Mock private ReleaseService releaseService; + @Mock + private RatingService ratingService; + @InjectMocks private ReleaseController releaseController; private static final Long ARTIST_ID = 1L; private static final Long RELEASE_ID = 10L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(releaseController) .setControllerAdvice(new GlobalExceptionHandler(new com.Coming.Backend.common.discord.NoOpDiscordNotifier())) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver(), new AuthenticationPrincipalArgumentResolver()) + .setValidator(validator) .build(); } + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + // ------------------------------------------------------------------------- // GET /api/releases // ------------------------------------------------------------------------- @@ -62,7 +89,7 @@ void should_return_200_with_all_releases_when_no_filters_given() throws Exceptio // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -87,7 +114,7 @@ void should_pass_query_param_to_service_when_q_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -108,7 +135,7 @@ void should_pass_artist_id_to_service_when_artist_id_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Single", LocalDate.of(2021, 3, 25), null + "LILAC", "Single", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -128,7 +155,7 @@ void should_pass_type_to_service_when_type_given() throws Exception { // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -164,7 +191,7 @@ void should_pass_following_true_to_service_when_following_param_given() throws E // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -186,7 +213,7 @@ void should_pass_all_filters_to_service_when_q_artist_id_and_type_given_together // given ReleaseListItemResponse item = new ReleaseListItemResponse( RELEASE_ID, "https://cover.example.com/10", "IU", null, - "LILAC", "Album", LocalDate.of(2021, 3, 25), null + "LILAC", "Album", LocalDate.of(2021, 3, 25), null, null, 0 ); PageResponse pageResponse = new PageResponse<>(List.of(item), 0, 20, 1, 1); @@ -233,7 +260,7 @@ void should_return_200_with_release_detail_when_release_exists() throws Exceptio ReleaseDetailResponse detail = new ReleaseDetailResponse( RELEASE_ID, "LILAC", "Album", LocalDate.of(2021, 3, 25), "https://cover.example.com/10", "KAKAO M", 2, - ARTIST_ID, "IU", null, null, List.of(track1, track2) + ARTIST_ID, "IU", null, null, List.of(track1, track2), null, 0 ); given(releaseService.getReleaseDetail(eq(RELEASE_ID))).willReturn(detail); @@ -266,4 +293,81 @@ void should_return_404_when_release_not_found() throws Exception { .andExpect(jsonPath("$.code").value(ErrorCode.RELEASE_NOT_FOUND.name())) .andExpect(jsonPath("$.message").exists()); } + + // ------------------------------------------------------------------------- + // PUT /api/releases/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_valid_score_given() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", RELEASE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isOk()); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); + } + + @Test + void should_return_400_when_score_is_out_of_range() throws Exception { + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", RELEASE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 0.2}")) + .andExpect(status().isBadRequest()); + } + + @Test + void should_return_404_when_rating_target_does_not_exist() throws Exception { + // given + willThrow(new RatingTargetNotFoundException()) + .given(ratingService).upsert(isNull(), eq(RatingTargetType.RELEASE), eq(999L), any(BigDecimal.class)); + + // when & then + mockMvc.perform(put("/api/releases/{id}/rating", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\": 4.5}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.RATING_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + // ------------------------------------------------------------------------- + // GET /api/releases/{id}/rating/me + // ------------------------------------------------------------------------- + + @Test + void should_return_200_with_my_score_when_rating_exists() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + given(ratingService.getMine(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID))) + .willReturn(new RatingMeResponse(BigDecimal.valueOf(4.5))); + + // when & then + mockMvc.perform(get("/api/releases/{id}/rating/me", RELEASE_ID) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(4.5)); + } + + // ------------------------------------------------------------------------- + // DELETE /api/releases/{id}/rating + // ------------------------------------------------------------------------- + + @Test + void should_return_200_when_rating_deleted() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + // when & then + mockMvc.perform(delete("/api/releases/{id}/rating", RELEASE_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); + } } diff --git a/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java b/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java index a07b7f9..91341a2 100644 --- a/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java +++ b/src/test/java/com/Coming/Backend/release/service/ReleaseServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -18,6 +19,9 @@ import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.common.exception.InvalidInputException; import com.Coming.Backend.common.response.PageResponse; +import com.Coming.Backend.rating.dto.RatingSummary; +import com.Coming.Backend.rating.entity.RatingTargetType; +import com.Coming.Backend.rating.service.RatingService; import com.Coming.Backend.release.dto.ArtistReleaseItemResponse; import com.Coming.Backend.release.dto.ReleaseDetailResponse; import com.Coming.Backend.release.dto.ReleaseListItemResponse; @@ -30,9 +34,11 @@ import java.time.LocalDate; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -64,11 +70,21 @@ class ReleaseServiceTest { @Mock private UserFollowArtistRepository userFollowArtistRepository; + @Mock + private RatingService ratingService; + private static final Long ARTIST_ID = 1L; private static final Long USER_ID = 100L; private static final Long RELEASE_ID = 10L; private static final Pageable PAGEABLE = PageRequest.of(0, 20); + @BeforeEach + void setUp() { + // 평점 집계는 대부분의 테스트와 무관하므로 기본값(빈 맵)을 lenient로 스텁한다. + // 평점 매핑을 직접 검증하는 테스트는 개별적으로 given()을 재정의한다. + lenient().when(ratingService.getSummaries(any(), any())).thenReturn(Map.of()); + } + private ReleaseGroup buildRelease(Long id, Long artistId, String type) { return ReleaseGroup.builder() .id(id) @@ -341,6 +357,33 @@ void should_combine_q_artist_id_and_type_filters_when_all_given() { eq(ARTIST_ID), isNull(), eq("Album"), eq("%lilac%"), any(Pageable.class)); } + @Test + void should_map_rating_summary_per_release_and_use_default_when_only_some_releases_have_ratings() { + // given + Long otherReleaseId = 20L; + ReleaseGroup rated = buildRelease(RELEASE_ID, ARTIST_ID, "Album"); + ReleaseGroup unrated = buildRelease(otherReleaseId, ARTIST_ID, "Single"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + RatingSummary ratingSummary = new RatingSummary(4.5, 3L); + Page page = new PageImpl<>(List.of(rated, unrated), PAGEABLE, 2); + given(releaseGroupRepository.searchReleases( + isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(page); + given(artistRepository.findAllById(Set.of(ARTIST_ID))).willReturn(List.of(artist)); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID, otherReleaseId))) + .willReturn(Map.of(RELEASE_ID, ratingSummary)); + + // when + PageResponse response = + releaseService.getReleases(null, null, null, null, false, PAGEABLE); + + // then + assertThat(response.content().get(0).averageRating()).isEqualTo(4.5); + assertThat(response.content().get(0).ratingCount()).isEqualTo(3L); + assertThat(response.content().get(1).averageRating()).isNull(); + assertThat(response.content().get(1).ratingCount()).isZero(); + } + // ------------------------------------------------------------------------- // getReleases — following=true // ------------------------------------------------------------------------- @@ -429,6 +472,45 @@ void should_return_release_detail_with_tracks_when_release_exists() { assertThat(response.tracks().get(0).title()).isEqualTo("트랙 1"); } + @Test + void should_return_rating_summary_when_release_has_ratings() { + // given + ReleaseGroup release = buildRelease(RELEASE_ID, ARTIST_ID, "ALBUM"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + RatingSummary ratingSummary = new RatingSummary(3.5, 8L); + given(releaseGroupRepository.findById(RELEASE_ID)).willReturn(Optional.of(release)); + given(artistRepository.findById(ARTIST_ID)).willReturn(Optional.of(artist)); + given(trackRepository.findByReleaseGroupIdOrderByPosition(RELEASE_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID))) + .willReturn(Map.of(RELEASE_ID, ratingSummary)); + + // when + ReleaseDetailResponse response = releaseService.getReleaseDetail(RELEASE_ID); + + // then + assertThat(response.averageRating()).isEqualTo(3.5); + assertThat(response.ratingCount()).isEqualTo(8L); + } + + @Test + void should_return_null_average_rating_and_zero_rating_count_when_release_has_no_ratings() { + // given + ReleaseGroup release = buildRelease(RELEASE_ID, ARTIST_ID, "ALBUM"); + Artist artist = buildArtist(ARTIST_ID, "IU"); + given(releaseGroupRepository.findById(RELEASE_ID)).willReturn(Optional.of(release)); + given(artistRepository.findById(ARTIST_ID)).willReturn(Optional.of(artist)); + given(trackRepository.findByReleaseGroupIdOrderByPosition(RELEASE_ID)).willReturn(List.of()); + given(ratingService.getSummaries(RatingTargetType.RELEASE, List.of(RELEASE_ID))) + .willReturn(Map.of()); + + // when + ReleaseDetailResponse response = releaseService.getReleaseDetail(RELEASE_ID); + + // then + assertThat(response.averageRating()).isNull(); + assertThat(response.ratingCount()).isZero(); + } + @Test void should_throw_release_not_found_when_release_does_not_exist() { // given diff --git a/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java new file mode 100644 index 0000000..494852b --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/controller/ReportControllerTest.java @@ -0,0 +1,187 @@ +package com.Coming.Backend.report.controller; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.service.ReportService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ReportControllerTest { + + private MockMvc mockMvc; + + @Mock + private ReportService reportService; + + @InjectMocks + private ReportController reportController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final Long USER_ID = 1L; + private static final Long REPORT_ID = 100L; + private static final Long POST_ID = 10L; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(reportController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .setValidator(validator) + .build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + USER_ID, null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ------------------------------------------------------------------------- + // POST /api/reports + // ------------------------------------------------------------------------- + + @Test + void should_return_201_when_create_request_is_valid() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willReturn(new ReportCreateResponse(REPORT_ID)); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(REPORT_ID)); + } + + @Test + void should_return_400_when_target_type_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":null,"targetId":10,"reason":"SPAM","detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_target_id_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":"POST","targetId":null,"reason":"SPAM","detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_reason_is_null() throws Exception { + // given + String requestJson = """ + {"targetType":"POST","targetId":10,"reason":null,"detail":null} + """; + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())); + } + + @Test + void should_return_400_when_service_throws_report_detail_required_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportDetailRequiredException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_DETAIL_REQUIRED.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_404_when_service_throws_report_target_not_found_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportTargetNotFoundException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_TARGET_NOT_FOUND.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_service_throws_report_already_exists_exception() throws Exception { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(reportService.create(eq(USER_ID), any(ReportCreateRequest.class))).willThrow(new ReportAlreadyExistsException()); + + // when & then + mockMvc.perform(post("/api/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.REPORT_ALREADY_EXISTS.name())) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java b/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java new file mode 100644 index 0000000..19e9a60 --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/event/ReportEventListenerTest.java @@ -0,0 +1,44 @@ +package com.Coming.Backend.report.event; + +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.discord.DiscordNotifier; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ReportEventListenerTest { + + @InjectMocks + private ReportEventListener reportEventListener; + + @Mock + private DiscordNotifier discordNotifier; + + @Test + void should_call_discord_notifier_when_report_created_event_received() { + // given + Report report = Report.builder() + .reporterId(1L) + .targetType(ReportTargetType.POST) + .targetId(100L) + .reason(ReportReason.SPAM) + .detail("도배성 게시글입니다.") + .status(ReportStatus.PENDING) + .build(); + ReportCreatedEvent event = new ReportCreatedEvent(report); + + // when + reportEventListener.onReportCreated(event); + + // then + verify(discordNotifier).notifyReport(report); + } +} diff --git a/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java b/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java new file mode 100644 index 0000000..4093dda --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/repository/ReportRepositoryTest.java @@ -0,0 +1,87 @@ +package com.Coming.Backend.report.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportStatus; +import com.Coming.Backend.report.entity.ReportTargetType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReportRepositoryTest { + + @Autowired + private ReportRepository reportRepository; + + private static final Long REPORTER_ID = 1L; + private static final Long OTHER_REPORTER_ID = 2L; + private static final Long TARGET_ID = 10L; + private static final Long OTHER_TARGET_ID = 20L; + + private Report buildReport(Long reporterId, ReportTargetType targetType, Long targetId) { + return Report.builder() + .reporterId(reporterId) + .targetType(targetType) + .targetId(targetId) + .reason(ReportReason.SPAM) + .status(ReportStatus.PENDING) + .build(); + } + + @Test + void should_return_true_when_report_exists_with_same_reporter_target_type_and_target_id() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.POST, TARGET_ID); + + // then + assertThat(exists).isTrue(); + } + + @Test + void should_return_false_when_reporter_id_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + OTHER_REPORTER_ID, ReportTargetType.POST, TARGET_ID); + + // then + assertThat(exists).isFalse(); + } + + @Test + void should_return_false_when_target_type_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.COMMENT, TARGET_ID); + + // then + assertThat(exists).isFalse(); + } + + @Test + void should_return_false_when_target_id_differs() { + // given + reportRepository.save(buildReport(REPORTER_ID, ReportTargetType.POST, TARGET_ID)); + + // when + boolean exists = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + REPORTER_ID, ReportTargetType.POST, OTHER_TARGET_ID); + + // then + assertThat(exists).isFalse(); + } +} diff --git a/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java new file mode 100644 index 0000000..3108022 --- /dev/null +++ b/src/test/java/com/Coming/Backend/report/service/ReportServiceTest.java @@ -0,0 +1,199 @@ +package com.Coming.Backend.report.service; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.post.repository.CommentRepository; +import com.Coming.Backend.post.repository.PostRepository; +import com.Coming.Backend.report.dto.ReportCreateRequest; +import com.Coming.Backend.report.dto.ReportCreateResponse; +import com.Coming.Backend.report.entity.Report; +import com.Coming.Backend.report.entity.ReportReason; +import com.Coming.Backend.report.entity.ReportTargetType; +import com.Coming.Backend.report.event.ReportCreatedEvent; +import com.Coming.Backend.report.exception.ReportAlreadyExistsException; +import com.Coming.Backend.report.exception.ReportDetailRequiredException; +import com.Coming.Backend.report.exception.ReportTargetNotFoundException; +import com.Coming.Backend.report.repository.ReportRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @InjectMocks + private ReportService reportService; + + @Mock + private ReportRepository reportRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private static final Long REPORTER_ID = 1L; + private static final Long REPORT_ID = 100L; + private static final Long POST_ID = 10L; + private static final Long COMMENT_ID = 20L; + + // ------------------------------------------------------------------------- + // create + // ------------------------------------------------------------------------- + + @Test + void should_throw_report_detail_required_exception_when_reason_is_etc_and_detail_is_null() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, null); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportDetailRequiredException.class) + .hasMessage(ErrorCode.REPORT_DETAIL_REQUIRED.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_detail_required_exception_when_reason_is_etc_and_detail_is_blank() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, " "); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportDetailRequiredException.class) + .hasMessage(ErrorCode.REPORT_DETAIL_REQUIRED.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_create_report_when_reason_is_etc_and_detail_given_for_post_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.ETC, "스팸성 광고 게시글입니다"); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + ReportCreateResponse response = reportService.create(REPORTER_ID, request); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + } + + @Test + void should_create_report_when_reason_is_not_etc_and_detail_is_null_for_comment_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.COMMENT, COMMENT_ID, ReportReason.ABUSE, null); + given(commentRepository.existsById(COMMENT_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.COMMENT, COMMENT_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + ReportCreateResponse response = reportService.create(REPORTER_ID, request); + + // then + assertThat(response.id()).isEqualTo(REPORT_ID); + } + + @Test + void should_throw_report_target_not_found_exception_when_target_type_is_post_and_post_does_not_exist() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportTargetNotFoundException.class) + .hasMessage(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_target_not_found_exception_when_target_type_is_comment_and_comment_does_not_exist() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.COMMENT, COMMENT_ID, ReportReason.SPAM, null); + given(commentRepository.existsById(COMMENT_ID)).willReturn(false); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportTargetNotFoundException.class) + .hasMessage(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_throw_report_already_exists_exception_when_reporter_already_reported_target() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportAlreadyExistsException.class) + .hasMessage(ErrorCode.REPORT_ALREADY_EXISTS.getMessage()); + verify(reportRepository, never()).save(any(Report.class)); + } + + @Test + void should_publish_report_created_event_when_report_created_successfully() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willAnswer(invocation -> { + Report saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", REPORT_ID); + return saved; + }); + + // when + reportService.create(REPORTER_ID, request); + + // then + verify(eventPublisher).publishEvent(any(ReportCreatedEvent.class)); + } + + @Test + void should_throw_report_already_exists_exception_when_save_violates_unique_constraint() { + // given + ReportCreateRequest request = new ReportCreateRequest(ReportTargetType.POST, POST_ID, ReportReason.SPAM, null); + given(postRepository.existsById(POST_ID)).willReturn(true); + given(reportRepository.existsByReporterIdAndTargetTypeAndTargetId(REPORTER_ID, ReportTargetType.POST, POST_ID)) + .willReturn(false); + given(reportRepository.save(any(Report.class))).willThrow(new DataIntegrityViolationException("duplicate")); + + // when & then + assertThatThrownBy(() -> reportService.create(REPORTER_ID, request)) + .isInstanceOf(ReportAlreadyExistsException.class) + .hasMessage(ErrorCode.REPORT_ALREADY_EXISTS.getMessage()); + verify(eventPublisher, never()).publishEvent(any(ReportCreatedEvent.class)); + } +}