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 dbc2173..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,12 +114,16 @@ 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", 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 2b6655b..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, "존재하지 않는 릴리즈입니다."), @@ -70,6 +71,11 @@ public enum ErrorCode { 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/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/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/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/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