From 96f3bbf04942996e6e959ea8f9f0d4a3259208d6 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:03:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[feat]=20Rating=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공연·릴리즈 별점 평가(#163) 기능을 위한 Rating 엔티티, target_type enum, 마이그레이션, Repository, ErrorCode를 추가한다. Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/exception/ErrorCode.java | 5 + .../Coming/Backend/rating/entity/Rating.java | 48 ++++++ .../rating/entity/RatingTargetType.java | 5 + .../InvalidRatingScoreException.java | 11 ++ .../exception/RatingNotFoundException.java | 11 ++ .../RatingTargetNotFoundException.java | 11 ++ .../exception/UnauthorizedException.java | 11 ++ .../rating/repository/RatingRepository.java | 29 ++++ .../db/migration/V40__create_rating_table.sql | 12 ++ .../repository/RatingRepositoryTest.java | 157 ++++++++++++++++++ 10 files changed, 300 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/rating/entity/Rating.java create mode 100644 src/main/java/com/Coming/Backend/rating/entity/RatingTargetType.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/InvalidRatingScoreException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/RatingNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/RatingTargetNotFoundException.java create mode 100644 src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java create mode 100644 src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java create mode 100644 src/main/resources/db/migration/V40__create_rating_table.sql create mode 100644 src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java 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..0565c79 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -70,6 +70,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/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/exception/UnauthorizedException.java b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java new file mode 100644 index 0000000..8379e9b --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.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 UnauthorizedException extends BusinessException { + + public UnauthorizedException() { + super(ErrorCode.UNAUTHORIZED); + } +} 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..849310a --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -0,0 +1,29 @@ +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.Query; +import org.springframework.data.repository.query.Param; + +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); + + @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/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/rating/repository/RatingRepositoryTest.java b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java new file mode 100644 index 0000000..737773e --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java @@ -0,0 +1,157 @@ +package com.Coming.Backend.rating.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +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; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class RatingRepositoryTest { + + @Autowired + private RatingRepository ratingRepository; + + 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); + } +} From e06c870f5bcdeee70f6b504291044cc3b99e3f90 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:11:47 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[feat]=20=EB=B3=84=EC=A0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95=C2=B7=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RatingService와 DTO를 추가하고 ConcertController·ReleaseController에 PUT/GET(me)/DELETE 별점 엔드포인트를 붙인다. Co-Authored-By: Claude Sonnet 5 --- .../concert/controller/ConcertController.java | 40 +++ .../Backend/rating/dto/RatingMeResponse.java | 11 + .../Backend/rating/dto/RatingSummary.java | 7 + .../rating/dto/RatingUpsertRequest.java | 15 ++ .../Backend/rating/service/RatingService.java | 109 ++++++++ .../release/controller/ReleaseController.java | 40 +++ .../controller/ConcertControllerTest.java | 78 ++++++ .../rating/service/RatingServiceTest.java | 250 ++++++++++++++++++ .../controller/ReleaseControllerTest.java | 83 ++++++ 9 files changed, 633 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java create mode 100644 src/main/java/com/Coming/Backend/rating/dto/RatingUpsertRequest.java create mode 100644 src/main/java/com/Coming/Backend/rating/service/RatingService.java create mode 100644 src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java 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/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..2e21546 --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.rating.dto; + +public record RatingSummary( + Double averageRating, + long ratingCount +) { +} 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/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java new file mode 100644 index 0000000..5dab15f --- /dev/null +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -0,0 +1,109 @@ +package com.Coming.Backend.rating.service; + +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.exception.UnauthorizedException; +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 SCORE_STEP = BigDecimal.valueOf(0.5); + + private final RatingRepository ratingRepository; + private final ConcertRepository concertRepository; + private final ReleaseGroupRepository releaseGroupRepository; + + /** + * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, + * score가 0.5 단위가 아니면 InvalidRatingScoreException을 던진다. + */ + @Transactional + public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { + if (userId == null) { + throw new UnauthorizedException(); + } + if (score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { + throw new InvalidRatingScoreException(); + } + if (!targetExists(targetType, targetId)) { + throw new RatingTargetNotFoundException(); + } + + ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .ifPresentOrElse( + rating -> rating.updateScore(score), + () -> ratingRepository.save(Rating.builder() + .userId(userId) + .targetType(targetType) + .targetId(targetId) + .score(score) + .build()) + ); + } + + /** + * 내 별점을 조회한다. 등록한 적이 없으면 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 boolean targetExists(RatingTargetType targetType, Long targetId) { + return switch (targetType) { + case CONCERT -> concertRepository.existsById(targetId); + case RELEASE -> releaseGroupRepository.existsById(targetId); + }; + } +} 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/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java index 36b8545..9577944 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,6 +20,11 @@ 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.BeforeEach; @@ -40,6 +48,9 @@ class ConcertControllerTest { @Mock private ConcertService concertService; + @Mock + private RatingService ratingService; + @InjectMocks private ConcertController concertController; @@ -194,4 +205,71 @@ 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 { + // 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(isNull(), 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 + given(ratingService.getMine(isNull(), 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 { + // when & then + mockMvc.perform(delete("/api/concerts/{id}/rating", CONCERT_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); + } } 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..f81fff9 --- /dev/null +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -0,0 +1,250 @@ +package com.Coming.Backend.rating.service; + +import com.Coming.Backend.common.exception.ErrorCode; +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.exception.UnauthorizedException; +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.never; +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_rating_target_not_found_exception_when_concert_does_not_exist() { + // given + given(concertRepository.existsById(CONCERT_ID)).willReturn(false); + + // 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_save_new_rating_when_no_existing_rating_found() { + // given + BigDecimal score = BigDecimal.valueOf(4.5); + given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.empty()); + + // when + ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, score); + + // then + verify(ratingRepository).save(any(Rating.class)); + } + + @Test + void should_update_existing_rating_score_when_rating_already_exists() { + // given + Rating existingRating = Rating.builder() + .userId(USER_ID) + .targetType(RatingTargetType.CONCERT) + .targetId(CONCERT_ID) + .score(BigDecimal.valueOf(2.0)) + .build(); + given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) + .willReturn(Optional.of(existingRating)); + + // when + ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(3.5)); + + // then + assertThat(existingRating.getScore()).isEqualByComparingTo(BigDecimal.valueOf(3.5)); + verify(ratingRepository, never()).save(any(Rating.class)); + } + + // ------------------------------------------------------------------------- + // 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..68d1801 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,11 +16,16 @@ 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.BeforeEach; @@ -30,6 +39,7 @@ import org.springframework.http.MediaType; 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,6 +49,9 @@ class ReleaseControllerTest { @Mock private ReleaseService releaseService; + @Mock + private RatingService ratingService; + @InjectMocks private ReleaseController releaseController; @@ -47,9 +60,12 @@ class ReleaseControllerTest { @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()) + .setValidator(validator) .build(); } @@ -266,4 +282,71 @@ 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 { + // 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(isNull(), 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 + given(ratingService.getMine(isNull(), 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 { + // when & then + mockMvc.perform(delete("/api/releases/{id}/rating", RELEASE_ID)) + .andExpect(status().isOk()); + verify(ratingService).delete(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); + } } From c928f23d1cb3b6a40a0e2c605020809e8aa410cb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:16:44 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=97=B0=C2=B7=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=20=EB=B3=84=EC=A0=90=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concert/ReleaseService가 RatingService 집계를 조회해 상세·목록 응답에 averageRating, ratingCount 필드를 채운다. Co-Authored-By: Claude Sonnet 5 --- .../concert/dto/ConcertDetailResponse.java | 4 +- .../concert/dto/ConcertSummaryResponse.java | 4 +- .../concert/service/ConcertService.java | 18 +++- .../release/dto/ReleaseDetailResponse.java | 11 ++- .../release/dto/ReleaseListItemResponse.java | 11 ++- .../release/service/ReleaseService.java | 23 ++++-- .../controller/ConcertControllerTest.java | 4 +- .../concert/service/ConcertServiceTest.java | 81 ++++++++++++++++++ .../controller/ReleaseControllerTest.java | 14 ++-- .../release/service/ReleaseServiceTest.java | 82 +++++++++++++++++++ 10 files changed, 229 insertions(+), 23 deletions(-) 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/service/ConcertService.java b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java index bd5d16a..5f7127e 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; @@ -53,6 +56,7 @@ public class ConcertService { private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); + private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -64,6 +68,7 @@ public class ConcertService { private final UserFollowArtistRepository userFollowArtistRepository; private final SetlistRepository setlistRepository; private final SetlistTrackRepository setlistTrackRepository; + private final RatingService ratingService; /** * 예정·진행 중 공연을 우선 노출하고 이후 조회수 순으로 상위 10건의 인기 공연 목록을 반환한다. @@ -211,6 +216,9 @@ 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, EMPTY_RATING_SUMMARY); + 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,12 @@ 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(), EMPTY_RATING_SUMMARY); return new ConcertSummaryResponse( concert.getId(), concert.getPosterUrl(), @@ -254,7 +266,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/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..767ed77 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; @@ -40,12 +43,14 @@ public class ReleaseService { private static final List STANDARD_TYPES = List.of("Album", "Single"); + private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ReleaseGroupRepository releaseGroupRepository; private final TrackRepository trackRepository; private final ArtistRepository artistRepository; private final ArtistAliasRepository artistAliasRepository; private final UserFollowArtistRepository userFollowArtistRepository; + private final RatingService ratingService; /** * 아티스트의 디스코그래피를 조회한다. types가 비어 있으면 전체 타입을 반환한다. @@ -102,10 +107,14 @@ 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(), EMPTY_RATING_SUMMARY); + return ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId()), + ratingSummary.averageRating(), ratingSummary.ratingCount()); + })); } /** @@ -140,7 +149,11 @@ 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(), EMPTY_RATING_SUMMARY); + + return ReleaseDetailResponse.of(release, artistName, artistKoreanName, tracks, + ratingSummary.averageRating(), ratingSummary.ratingCount()); } private Map buildKoreanNameMap(List artistIds) { 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 9577944..9aa6d30 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -81,7 +81,9 @@ private ConcertSummaryResponse buildSummary(Long id, String title, String artist "올림픽공원", ConcertStatus.UPCOMING, false, - null + null, + null, + 0 ); } 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/release/controller/ReleaseControllerTest.java b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java index 68d1801..3f6d219 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -78,7 +78,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); @@ -103,7 +103,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); @@ -124,7 +124,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); @@ -144,7 +144,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); @@ -180,7 +180,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); @@ -202,7 +202,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); @@ -249,7 +249,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); 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 From dae1adcda058dbcef8cfc256444d2008174642f5 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:24:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[style]=20be-review=C2=B7simplify=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(=ED=8F=AC=EB=A7=B7=C2=B7=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=C2=B7=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=B4=EA=B0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 100자 초과 라인 정리 - rating.exception.UnauthorizedException 중복 제거, concert 것 재사용 - EMPTY_RATING_SUMMARY 중복 제거, RatingSummary.empty() 팩토리로 통합 - SecurityConfig에 GET .../rating/me 인증 carve-out 추가 Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/config/SecurityConfig.java | 4 ++++ .../Backend/concert/service/ConcertService.java | 12 +++++++----- .../Coming/Backend/rating/dto/RatingSummary.java | 3 +++ .../rating/exception/UnauthorizedException.java | 11 ----------- .../rating/repository/RatingRepository.java | 6 ++++-- .../Backend/rating/service/RatingService.java | 8 +++++--- .../Backend/release/service/ReleaseService.java | 16 ++++++++++------ .../rating/service/RatingServiceTest.java | 2 +- 8 files changed, 34 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java 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..87c1b99 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -114,6 +114,10 @@ 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" + ).hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/artists/**", "/api/concerts/**", 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 5f7127e..62f8278 100644 --- a/src/main/java/com/Coming/Backend/concert/service/ConcertService.java +++ b/src/main/java/com/Coming/Backend/concert/service/ConcertService.java @@ -56,7 +56,6 @@ public class ConcertService { private static final List HIDDEN_STATUSES = List.of(EXCLUDED, PENDING); - private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ConcertRepository concertRepository; private final ConcertArtistRepository concertArtistRepository; @@ -216,8 +215,9 @@ 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, EMPTY_RATING_SUMMARY); + RatingSummary ratingSummary = ratingService + .getSummaries(RatingTargetType.CONCERT, List.of(id)) + .getOrDefault(id, RatingSummary.empty()); return new ConcertDetailResponse( concert.getId(), @@ -250,12 +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); + 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(), EMPTY_RATING_SUMMARY); + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(concert.getId(), RatingSummary.empty()); return new ConcertSummaryResponse( concert.getId(), concert.getPosterUrl(), diff --git a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java index 2e21546..04235c8 100644 --- a/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java +++ b/src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java @@ -4,4 +4,7 @@ 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/exception/UnauthorizedException.java b/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java deleted file mode 100644 index 8379e9b..0000000 --- a/src/main/java/com/Coming/Backend/rating/exception/UnauthorizedException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.Coming.Backend.rating.exception; - -import com.Coming.Backend.common.exception.BusinessException; -import com.Coming.Backend.common.exception.ErrorCode; - -public class UnauthorizedException extends BusinessException { - - public UnauthorizedException() { - super(ErrorCode.UNAUTHORIZED); - } -} diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java index 849310a..ffedb9b 100644 --- a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -12,10 +12,12 @@ public interface RatingRepository extends JpaRepository { - Optional findByUserIdAndTargetTypeAndTargetId(Long userId, RatingTargetType targetType, Long targetId); + Optional findByUserIdAndTargetTypeAndTargetId( + Long userId, RatingTargetType targetType, Long targetId); @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") + "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); diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 5dab15f..8240dc1 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -1,5 +1,6 @@ package com.Coming.Backend.rating.service; +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; @@ -8,7 +9,6 @@ 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.exception.UnauthorizedException; import com.Coming.Backend.rating.repository.RatingRepository; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import lombok.RequiredArgsConstructor; @@ -79,7 +79,8 @@ public void delete(Long userId, RatingTargetType targetType, Long targetId) { if (userId == null) { throw new UnauthorizedException(); } - Rating rating = ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + Rating rating = ratingRepository + .findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) .orElseThrow(RatingNotFoundException::new); ratingRepository.delete(rating); } @@ -87,7 +88,8 @@ public void delete(Long userId, RatingTargetType targetType, Long targetId) { /** * 대상 ID 목록에 대한 평균 별점·평가 개수를 조회한다. 별점이 없는 대상은 결과 맵에 포함되지 않는다. */ - public Map getSummaries(RatingTargetType targetType, Collection targetIds) { + public Map getSummaries(RatingTargetType targetType, + Collection targetIds) { if (targetIds.isEmpty()) { return Map.of(); } 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 767ed77..4a75042 100644 --- a/src/main/java/com/Coming/Backend/release/service/ReleaseService.java +++ b/src/main/java/com/Coming/Backend/release/service/ReleaseService.java @@ -43,7 +43,6 @@ public class ReleaseService { private static final List STANDARD_TYPES = List.of("Album", "Single"); - private static final RatingSummary EMPTY_RATING_SUMMARY = new RatingSummary(null, 0); private final ReleaseGroupRepository releaseGroupRepository; private final TrackRepository trackRepository; @@ -108,11 +107,15 @@ public PageResponse getReleases(String q, Long artistId .collect(Collectors.toMap(Artist::getId, Artist::getName)); Map koreanNameMap = buildKoreanNameMap(List.copyOf(artistIds)); List releaseIds = page.stream().map(ReleaseGroup::getId).toList(); - Map ratingSummaryMap = ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); + Map ratingSummaryMap = + ratingService.getSummaries(RatingTargetType.RELEASE, releaseIds); return PageResponse.from(page.map(release -> { - RatingSummary ratingSummary = ratingSummaryMap.getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); - return ReleaseListItemResponse.of(release, artistNameMap.getOrDefault(release.getArtistId(), ""), koreanNameMap.get(release.getArtistId()), + RatingSummary ratingSummary = + ratingSummaryMap.getOrDefault(release.getId(), RatingSummary.empty()); + return ReleaseListItemResponse.of(release, + artistNameMap.getOrDefault(release.getArtistId(), ""), + koreanNameMap.get(release.getArtistId()), ratingSummary.averageRating(), ratingSummary.ratingCount()); })); } @@ -149,8 +152,9 @@ public ReleaseDetailResponse getReleaseDetail(Long id) { List tracks = trackRepository.findByReleaseGroupIdOrderByPosition(release.getId()) .stream().map(TrackDto::from).toList(); - RatingSummary ratingSummary = ratingService.getSummaries(RatingTargetType.RELEASE, List.of(release.getId())) - .getOrDefault(release.getId(), EMPTY_RATING_SUMMARY); + 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()); diff --git a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java index f81fff9..e1655d0 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -1,6 +1,7 @@ package com.Coming.Backend.rating.service; import com.Coming.Backend.common.exception.ErrorCode; +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; @@ -9,7 +10,6 @@ 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.exception.UnauthorizedException; import com.Coming.Backend.rating.repository.RatingRepository; import com.Coming.Backend.release.repository.ReleaseGroupRepository; import org.junit.jupiter.api.Test; From 4bfb4c7d5e0d599c82f08bebe254e93b0a819e8f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Sun, 20 Sep 2026 23:54:17 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=97=B0=20=EB=B3=84?= =?UTF-8?q?=EC=A0=90=20=EB=93=B1=EB=A1=9D=EC=97=90=20ENDED=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE에서 공연 상태가 ENDED일 때만 별점 입력 UI를 노출하도록 정책을 변경함에 따라, 서버 측에서도 concert.status가 ENDED가 아니면 별점 등록·수정을 거부하도록 검증을 추가했다. release 별점은 정책 변경 대상이 아니므로 기존과 동일하게 상태 검증 없이 항상 허용한다. Co-Authored-By: Claude Sonnet 5 --- .../Backend/common/exception/ErrorCode.java | 1 + .../exception/ConcertNotEndedException.java | 11 +++++ .../Backend/rating/service/RatingService.java | 30 +++++++++---- .../rating/service/RatingServiceTest.java | 45 +++++++++++++++++-- 4 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/concert/exception/ConcertNotEndedException.java 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 0565c79..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, "존재하지 않는 릴리즈입니다."), 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/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 8240dc1..31b1d7a 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -1,5 +1,8 @@ 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; @@ -33,7 +36,8 @@ public class RatingService { /** * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, - * score가 0.5 단위가 아니면 InvalidRatingScoreException을 던진다. + * score가 0.5 단위가 아니면 InvalidRatingScoreException, + * 공연이 ENDED 상태가 아니면 ConcertNotEndedException을 던진다. */ @Transactional public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigDecimal score) { @@ -43,9 +47,7 @@ public void upsert(Long userId, RatingTargetType targetType, Long targetId, BigD if (score.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { throw new InvalidRatingScoreException(); } - if (!targetExists(targetType, targetId)) { - throw new RatingTargetNotFoundException(); - } + validateTarget(targetType, targetId); ratingRepository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) .ifPresentOrElse( @@ -102,10 +104,20 @@ public Map getSummaries(RatingTargetType targetType, )); } - private boolean targetExists(RatingTargetType targetType, Long targetId) { - return switch (targetType) { - case CONCERT -> concertRepository.existsById(targetId); - case RELEASE -> releaseGroupRepository.existsById(targetId); - }; + 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/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java index e1655d0..2c29edc 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -1,6 +1,9 @@ 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; @@ -77,7 +80,7 @@ void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { @Test void should_throw_rating_target_not_found_exception_when_concert_does_not_exist() { // given - given(concertRepository.existsById(CONCERT_ID)).willReturn(false); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(4.5))) @@ -85,11 +88,28 @@ void should_throw_rating_target_not_found_exception_when_concert_does_not_exist( .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_save_new_rating_when_no_existing_rating_found() { // given BigDecimal score = BigDecimal.valueOf(4.5); - given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + Concert concert = Concert.builder() + .status(ConcertStatus.ENDED) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) .willReturn(Optional.empty()); @@ -109,7 +129,10 @@ void should_update_existing_rating_score_when_rating_already_exists() { .targetId(CONCERT_ID) .score(BigDecimal.valueOf(2.0)) .build(); - given(concertRepository.existsById(CONCERT_ID)).willReturn(true); + Concert concert = Concert.builder() + .status(ConcertStatus.ENDED) + .build(); + given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) .willReturn(Optional.of(existingRating)); @@ -121,6 +144,22 @@ void should_update_existing_rating_score_when_rating_already_exists() { verify(ratingRepository, never()).save(any(Rating.class)); } + @Test + void should_save_new_rating_when_release_target_exists() { + // given + Long releaseId = 20L; + BigDecimal score = BigDecimal.valueOf(4.0); + given(releaseGroupRepository.existsById(releaseId)).willReturn(true); + given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.RELEASE, releaseId)) + .willReturn(Optional.empty()); + + // when + ratingService.upsert(USER_ID, RatingTargetType.RELEASE, releaseId, score); + + // then + verify(ratingRepository).save(any(Rating.class)); + } + // ------------------------------------------------------------------------- // getMine // ------------------------------------------------------------------------- From 91024f77721de1cd32f816202a5827841ccf1c24 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 21 Sep 2026 17:50:11 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[fix]=20=EB=B3=84=EC=A0=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=EC=97=90=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit score 범위 검증(0.5~5.0)이 서비스 계층에서 빠져있어 컨트롤러 우회 시 잘못된 값이 저장될 수 있는 문제와, 조회 후 삽입 방식의 upsert가 동시 첫 등록 요청에서 유니크 제약 위반으로 500을 반환하던 레이스 컨디션을 DB 원자적 upsert(INSERT ... ON CONFLICT)로 해결했다. 아울러 rating 컨트롤러 테스트가 인증된 사용자 ID 전파를 검증하지 못하던 공백과 테스트 파일의 import 순서 컨벤션 위반도 함께 정리했다. Co-Authored-By: Claude Sonnet 5 --- .../rating/repository/RatingRepository.java | 16 +++++ .../Backend/rating/service/RatingService.java | 22 +++---- .../controller/ConcertControllerTest.java | 29 +++++++-- .../repository/RatingRepositoryTest.java | 47 ++++++++++++++- .../rating/service/RatingServiceTest.java | 59 ++++++++----------- .../controller/ReleaseControllerTest.java | 29 +++++++-- 6 files changed, 146 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java index ffedb9b..538b348 100644 --- a/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java +++ b/src/main/java/com/Coming/Backend/rating/repository/RatingRepository.java @@ -3,9 +3,11 @@ 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; @@ -15,6 +17,20 @@ 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") diff --git a/src/main/java/com/Coming/Backend/rating/service/RatingService.java b/src/main/java/com/Coming/Backend/rating/service/RatingService.java index 31b1d7a..f095424 100644 --- a/src/main/java/com/Coming/Backend/rating/service/RatingService.java +++ b/src/main/java/com/Coming/Backend/rating/service/RatingService.java @@ -28,6 +28,8 @@ @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; @@ -36,29 +38,23 @@ public class RatingService { /** * 별점을 등록하거나 수정한다. 대상이 존재하지 않으면 RatingTargetNotFoundException, - * score가 0.5 단위가 아니면 InvalidRatingScoreException, + * 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.remainder(SCORE_STEP).compareTo(BigDecimal.ZERO) != 0) { + 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.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) - .ifPresentOrElse( - rating -> rating.updateScore(score), - () -> ratingRepository.save(Rating.builder() - .userId(userId) - .targetType(targetType) - .targetId(targetId) - .score(score) - .build()) - ); + ratingRepository.upsert(userId, targetType.name(), targetId, score); } /** 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 9aa6d30..6bc59af 100644 --- a/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java +++ b/src/test/java/com/Coming/Backend/concert/controller/ConcertControllerTest.java @@ -27,6 +27,7 @@ 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; @@ -36,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; @@ -55,6 +60,7 @@ class ConcertControllerTest { private ConcertController concertController; private static final Long CONCERT_ID = 1L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { @@ -62,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)) @@ -214,12 +225,16 @@ void should_return_401_with_error_body_when_following_true_and_user_not_authenti @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(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID), eq(BigDecimal.valueOf(4.5))); } @Test @@ -253,7 +268,9 @@ void should_return_404_when_rating_target_does_not_exist() throws Exception { @Test void should_return_200_with_my_score_when_rating_exists() throws Exception { // given - given(ratingService.getMine(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID))) + 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 @@ -269,9 +286,13 @@ void should_return_200_with_my_score_when_rating_exists() throws Exception { @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(isNull(), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.CONCERT), eq(CONCERT_ID)); } } diff --git a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java index 737773e..edb5e73 100644 --- a/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java +++ b/src/test/java/com/Coming/Backend/rating/repository/RatingRepositoryTest.java @@ -1,7 +1,5 @@ package com.Coming.Backend.rating.repository; -import static org.assertj.core.api.Assertions.assertThat; - import com.Coming.Backend.rating.entity.Rating; import com.Coming.Backend.rating.entity.RatingTargetType; import com.Coming.Backend.rating.repository.RatingRepository.RatingAggregate; @@ -12,6 +10,9 @@ 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) @@ -20,6 +21,9 @@ 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; @@ -154,4 +158,43 @@ void should_exclude_target_id_from_result_when_no_ratings_exist() { .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 index 2c29edc..0b0a8e8 100644 --- a/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java +++ b/src/test/java/com/Coming/Backend/rating/service/RatingServiceTest.java @@ -32,7 +32,6 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -77,6 +76,28 @@ void should_throw_invalid_rating_score_exception_when_score_is_not_half_step() { .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 @@ -103,61 +124,33 @@ void should_throw_concert_not_ended_exception_when_concert_status_is_not_ended() } @Test - void should_save_new_rating_when_no_existing_rating_found() { + 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)); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) - .willReturn(Optional.empty()); // when ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, score); // then - verify(ratingRepository).save(any(Rating.class)); + verify(ratingRepository).upsert(USER_ID, "CONCERT", CONCERT_ID, score); } @Test - void should_update_existing_rating_score_when_rating_already_exists() { - // given - Rating existingRating = Rating.builder() - .userId(USER_ID) - .targetType(RatingTargetType.CONCERT) - .targetId(CONCERT_ID) - .score(BigDecimal.valueOf(2.0)) - .build(); - Concert concert = Concert.builder() - .status(ConcertStatus.ENDED) - .build(); - given(concertRepository.findById(CONCERT_ID)).willReturn(Optional.of(concert)); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.CONCERT, CONCERT_ID)) - .willReturn(Optional.of(existingRating)); - - // when - ratingService.upsert(USER_ID, RatingTargetType.CONCERT, CONCERT_ID, BigDecimal.valueOf(3.5)); - - // then - assertThat(existingRating.getScore()).isEqualByComparingTo(BigDecimal.valueOf(3.5)); - verify(ratingRepository, never()).save(any(Rating.class)); - } - - @Test - void should_save_new_rating_when_release_target_exists() { + 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); - given(ratingRepository.findByUserIdAndTargetTypeAndTargetId(USER_ID, RatingTargetType.RELEASE, releaseId)) - .willReturn(Optional.empty()); // when ratingService.upsert(USER_ID, RatingTargetType.RELEASE, releaseId, score); // then - verify(ratingRepository).save(any(Rating.class)); + verify(ratingRepository).upsert(USER_ID, "RELEASE", releaseId, score); } // ------------------------------------------------------------------------- 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 3f6d219..3e05ce1 100644 --- a/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java +++ b/src/test/java/com/Coming/Backend/release/controller/ReleaseControllerTest.java @@ -28,6 +28,7 @@ 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; @@ -37,6 +38,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; @@ -57,6 +62,7 @@ class ReleaseControllerTest { private static final Long ARTIST_ID = 1L; private static final Long RELEASE_ID = 10L; + private static final Long USER_ID = 1L; @BeforeEach void setUp() { @@ -64,11 +70,16 @@ void setUp() { 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 // ------------------------------------------------------------------------- @@ -289,12 +300,16 @@ void should_return_404_when_release_not_found() throws Exception { @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(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); + verify(ratingService).upsert(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID), eq(BigDecimal.valueOf(4.5))); } @Test @@ -328,7 +343,9 @@ void should_return_404_when_rating_target_does_not_exist() throws Exception { @Test void should_return_200_with_my_score_when_rating_exists() throws Exception { // given - given(ratingService.getMine(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID))) + 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 @@ -344,9 +361,13 @@ void should_return_200_with_my_score_when_rating_exists() throws Exception { @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(isNull(), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); + verify(ratingService).delete(eq(USER_ID), eq(RatingTargetType.RELEASE), eq(RELEASE_ID)); } } From 761e9d394e5b10293f026d18b39566f9474f2897 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 21 Sep 2026 19:02:51 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[fix]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EA=B2=80=EC=83=89=20API=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/mentions/search가 permitAll 목록에 포함되어 인증 없이 호출 가능했다. 해당 API는 게시글 작성 시 멘션 자동완성 용도로 인증된 사용자만 사용해야 하므로, rating/me·following과 동일하게 hasAnyRole("USER", "ADMIN") 그룹으로 이동했다. Co-Authored-By: Claude Sonnet 5 --- .../java/com/Coming/Backend/common/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 87c1b99..b707ea3 100644 --- a/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/SecurityConfig.java @@ -116,14 +116,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/api/artists/following").hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/concerts/*/rating/me", - "/api/releases/*/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",