diff --git a/build.gradle b/build.gradle index 0ed16e13..0528b1c5 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,10 @@ dependencies { implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' implementation 'org.mapstruct:mapstruct:1.6.3' + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // AWS SDK (S3) implementation platform('software.amazon.awssdk:bom:2.46.7') implementation 'software.amazon.awssdk:s3' diff --git a/src/main/java/com/codeit/team5/mopl/config/QueryDslConfig.java b/src/main/java/com/codeit/team5/mopl/config/QueryDslConfig.java new file mode 100644 index 00000000..eb96e459 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.codeit.team5.mopl.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/config/WebConfig.java b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java new file mode 100644 index 00000000..f8764463 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java @@ -0,0 +1,25 @@ +package com.codeit.team5.mopl.config; + +import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException; +import com.codeit.team5.mopl.watcher.constant.SortByType; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(String.class, Sort.Direction.class, value -> { + if (value.equalsIgnoreCase("ASCENDING") || value.equalsIgnoreCase("ASC")) { + return Sort.Direction.ASC; + } + if (value.equalsIgnoreCase("DESCENDING") || value.equalsIgnoreCase("DESC")) { + return Sort.Direction.DESC; + } + throw new InvalidSortDirectionException(value); + }); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java index 67863631..a33af4e6 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java @@ -1,19 +1,27 @@ package com.codeit.team5.mopl.content.controller; +import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; import com.codeit.team5.mopl.content.controller.api.ContentApi; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.ContentSortByType; +import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.content.service.ContentService; -import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; +import com.codeit.team5.mopl.global.dto.CursorResponse; import jakarta.validation.Valid; +import java.beans.PropertyEditorSupport; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -30,6 +38,22 @@ public class ContentController implements ContentApi { private final ContentService contentService; + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.registerCustomEditor(ContentType.class, new PropertyEditorSupport() { + @Override + public void setAsText(String text) { + setValue(ContentType.from(text)); + } + }); + binder.registerCustomEditor(ContentSortByType.class, new PropertyEditorSupport() { + @Override + public void setAsText(String text) { + setValue(ContentSortByType.from(text)); + } + }); + } + @Override @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity postContent( @@ -53,6 +77,22 @@ public ResponseEntity patchContent( return ResponseEntity.ok(response); } + @Override + @GetMapping("/{contentId}") + public ResponseEntity getContent(@PathVariable UUID contentId) { + log.info("Content Get request: GET /api/contents/{}", contentId); + ContentResponse response = contentService.findById(contentId); + return ResponseEntity.ok(response); + } + + @Override + @GetMapping + public ResponseEntity> getContents(@Valid ContentCursorRequest request) { + log.info("Content List request: GET /api/contents"); + CursorResponse response = contentService.findContents(request); + return ResponseEntity.ok(response); + } + @Override @DeleteMapping("/{contentId}") public ResponseEntity deleteContent(@PathVariable UUID contentId) { diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java index a7fbb770..f98acaeb 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java @@ -1,11 +1,17 @@ package com.codeit.team5.mopl.content.controller.api; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; +import com.codeit.team5.mopl.content.entity.ContentSortByType; +import com.codeit.team5.mopl.content.entity.ContentType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Encoding; import io.swagger.v3.oas.annotations.media.Schema; @@ -13,6 +19,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Sort; import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -55,13 +62,14 @@ ResponseEntity postContent( @Schema(name = "ContentUpdateMultipartRequest") class ContentUpdateMultipartRequest { + @Schema(implementation = ContentUpdateRequest.class) - public ContentCreateRequest request; + public ContentUpdateRequest request; @Schema(type = "string", format = "binary", description = "썸네일 이미지") public MultipartFile thumbnail; - } + } @Operation(summary = "[어드민] 콘텐츠 수정", description = "콘텐츠의 제목, 설명, 태그를 수정합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공", @@ -87,6 +95,47 @@ ResponseEntity patchContent( @Parameter(hidden = true) ContentUpdateRequest request, @Parameter(hidden = true) MultipartFile thumbnail ); + @Operation(summary = "콘텐츠 단건 조회", description = "콘텐츠 ID로 단건 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", + content = @Content(schema = @Schema(implementation = ContentResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "404", description = "콘텐츠 없음", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity getContent(@Parameter(description = "콘텐츠 ID") UUID contentId); + + @Operation(summary = "콘텐츠 목록 조회", description = "콘텐츠 목록을 커서 기반 페이지네이션으로 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + @Parameters({ + @Parameter(name = "typeEqual", description = "콘텐츠 타입", + schema = @Schema(implementation = ContentType.class)), + @Parameter(name = "keywordLike", description = "검색 키워드"), + @Parameter(name = "tagsIn", description = "태그 목록", + array = @ArraySchema(schema = @Schema(type = "string", example = "string"))), + @Parameter(name = "cursor", description = "커서"), + @Parameter(name = "idAfter", description = "보조 커서"), + @Parameter(name = "limit", description = "한 번에 가져올 개수", example = "20", required = true), + @Parameter(name = "sortDirection", description = "정렬 방향", required = true, + schema = @Schema(type = "string", allowableValues = {"ASCENDING", "DESCENDING"}, defaultValue = "DESCENDING")), + @Parameter(name = "sortBy", description = "정렬 기준", required = true, + schema = @Schema(type = "string", allowableValues = {"createdAt", "watcherCount", "rate"}, defaultValue = "createdAt")) + }) + ResponseEntity> getContents( + @Parameter(hidden = true) ContentCursorRequest request); @Operation(summary = "[어드민] 콘텐츠 삭제", description = "콘텐츠를 삭제합니다.") @ApiResponses({ @@ -103,4 +152,4 @@ ResponseEntity patchContent( content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity deleteContent(@Parameter(description = "콘텐츠 ID") UUID contentId); -} \ No newline at end of file +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCursorRequest.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCursorRequest.java new file mode 100644 index 00000000..69271c98 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCursorRequest.java @@ -0,0 +1,34 @@ +package com.codeit.team5.mopl.content.dto.request; + +import com.codeit.team5.mopl.content.entity.ContentSortByType; +import com.codeit.team5.mopl.content.entity.ContentType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; +import org.springframework.data.domain.Sort; + +@ValidCursor +public record ContentCursorRequest( + ContentType typeEqual, + + String keywordLike, + + List tagsIn, + + String cursor, + + String idAfter, + + @NotNull + @Positive + @Max(100) + Integer limit, + + @NotNull + Sort.Direction sortDirection, + + @NotNull + ContentSortByType sortBy +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursor.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursor.java new file mode 100644 index 00000000..25039cdd --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursor.java @@ -0,0 +1,20 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidCursorValidator.class) +public @interface ValidCursor { + + String message() default "커서 값이 유효하지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidator.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidator.java new file mode 100644 index 00000000..2b6ec004 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidator.java @@ -0,0 +1,40 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.Instant; +import java.util.UUID; + +public class ValidCursorValidator implements ConstraintValidator { + + @Override + public boolean isValid(ContentCursorRequest request, ConstraintValidatorContext context) { + String cursor = request.cursor(); + String idAfter = request.idAfter(); + + if ((cursor == null) ^ (idAfter == null)) return false; + + if (cursor == null) return true; + + try { + UUID.fromString(idAfter); + } catch (IllegalArgumentException e) { + return false; + } + + try { + switch (request.sortBy()) { + case CREATED_AT -> Instant.parse(cursor); + case WATCHER_COUNT -> Long.parseLong(cursor); + case RATE -> { + double val = Double.parseDouble(cursor); + if (!Double.isFinite(val)) return false; + } + } + } catch (Exception e) { + return false; + } + + return true; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index e191fe43..6ecc7c56 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -22,6 +22,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import org.springframework.util.StringUtils; @@ -73,6 +74,7 @@ public class Content extends BaseUpdatableEntity { private ContentStats stats; @OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 30) private Set contentTags = new HashSet<>(); public static Content createByAdmin(ContentType type, String title, String description) { diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentSortByType.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentSortByType.java new file mode 100644 index 00000000..6e87583e --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentSortByType.java @@ -0,0 +1,24 @@ +package com.codeit.team5.mopl.content.entity; + +import com.codeit.team5.mopl.content.exception.ContentIncorrectSortByException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ContentSortByType { + CREATED_AT("createdAt"), + WATCHER_COUNT("watcherCount"), + RATE("rate") + ; + private final String value; + + public static ContentSortByType from(String text) { + for (ContentSortByType type : ContentSortByType.values()) { + if (type.getValue().equalsIgnoreCase(text)) { + return type; + } + } + throw new ContentIncorrectSortByException(text); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java index e1a472cb..b40a3d05 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.content.entity; +import com.codeit.team5.mopl.content.exception.InvalidContentStatsException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -30,4 +31,23 @@ public static ContentStats create() { stats.watcherCount = 0; return stats; } + + public double getAverageRating() { + if (reviewCount == 0) return 0.0; + return ratingSum / reviewCount; + } + + public void updateRating(double newRatingSum, int newReviewCount) { + if (newReviewCount < 0) { + throw new InvalidContentStatsException("reviewCount는 음수일 수 없습니다."); + } + if (!Double.isFinite(newRatingSum) || newRatingSum < 0) { + throw new InvalidContentStatsException("ratingSum은 음수이거나 비정상 값(NaN, Infinity)일 수 없습니다."); + } + if (newReviewCount == 0 && newRatingSum != 0) { + throw new InvalidContentStatsException("reviewCount가 0이면 ratingSum도 0이어야 합니다."); + } + this.ratingSum = newRatingSum; + this.reviewCount = newReviewCount; + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java b/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java index 12c5782e..8dfdd83d 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java @@ -9,21 +9,21 @@ public enum ContentType { TV_SERIES("tvSeries"), SPORT("sport"); - private final String response; + private final String value; - ContentType(String response) { - this.response = response; + ContentType(String value) { + this.value = value; } @JsonValue - public String getResponse() { - return response; + public String getValue() { + return value; } @JsonCreator public static ContentType from(String value) { for (ContentType type : values()) { - if (type.response.equals(value)) { + if (type.value.equals(value)) { return type; } } diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/ContentIncorrectSortByException.java b/src/main/java/com/codeit/team5/mopl/content/exception/ContentIncorrectSortByException.java new file mode 100644 index 00000000..4f7ba4e2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/ContentIncorrectSortByException.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.content.exception; + +import java.util.Collections; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class ContentIncorrectSortByException extends ContentException { + + public ContentIncorrectSortByException(String sortBy) { + super(HttpStatus.BAD_REQUEST, "SortBy 입력값이 올바르지 않습니다.", + sortBy != null ? Map.of("incorrectSortBy", sortBy) : Collections.emptyMap()); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentStatsException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentStatsException.java new file mode 100644 index 00000000..8cd807f0 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentStatsException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import org.springframework.http.HttpStatus; + +public class InvalidContentStatsException extends ContentException { + + public InvalidContentStatsException(String message) { + super(HttpStatus.BAD_REQUEST, message); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index 771dc826..23b88f45 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -2,15 +2,13 @@ import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentStats; -import com.codeit.team5.mopl.content.entity.ContentTag; +import com.codeit.team5.mopl.content.entity.ContentSortByType; import com.codeit.team5.mopl.content.mapper.util.ContentUtilsMapper; -import java.util.Collections; +import com.codeit.team5.mopl.global.dto.CursorResponse; import java.util.List; -import java.util.Set; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.Named; +import org.springframework.data.domain.Sort.Direction; @Mapper(componentModel = "spring", uses = {ContentUtilsMapper.class}) public interface ContentMapper { @@ -22,4 +20,23 @@ public interface ContentMapper { @Mapping(target = "thumbnailUrl", source = "thumbnail.url") @Mapping(target = "thumbnailUploadStatus", source = "thumbnail.uploadStatus") ContentResponse toDto(Content content); + + default CursorResponse toCursor(List page, boolean hasNext, + long totalCount, ContentSortByType sortBy, Direction sortDirection) { + String nextCursor = null; + String nextIdAfter = null; + if (hasNext && !page.isEmpty()) { + Content last = page.get(page.size() - 1); + nextCursor = switch (sortBy) { + case CREATED_AT -> last.getCreatedAt().toString(); + case WATCHER_COUNT -> String.valueOf(last.getStats().getWatcherCount()); + case RATE -> String.valueOf(last.getStats().getAverageRating()); + }; + nextIdAfter = last.getId().toString(); + } + List data = page.stream().map(this::toDto).toList(); + String direction = sortDirection == Direction.ASC ? "ASCENDING" : "DESCENDING"; + return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, + sortBy.getValue(), direction); + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java index df544458..6a8cb997 100644 --- a/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java +++ b/src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java @@ -1,12 +1,13 @@ package com.codeit.team5.mopl.content.repository; import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.repository.querydsl.ContentQueryRepository; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; -public interface ContentRepository extends JpaRepository { +public interface ContentRepository extends JpaRepository, ContentQueryRepository { @EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"}) Optional findWithStatsAndTagsById(UUID id); diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepository.java b/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepository.java new file mode 100644 index 00000000..a8cac6a6 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepository.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.content.repository.querydsl; + +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; +import com.codeit.team5.mopl.content.entity.Content; +import java.util.List; + +public interface ContentQueryRepository { + + List findContents(ContentCursorRequest request, int fetchLimit); + + long countContents(ContentCursorRequest request); +} diff --git a/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepositoryImpl.java new file mode 100644 index 00000000..a7fd4cdc --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/repository/querydsl/ContentQueryRepositoryImpl.java @@ -0,0 +1,157 @@ +package com.codeit.team5.mopl.content.repository.querydsl; + +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.entity.QContent; +import com.codeit.team5.mopl.content.entity.QContentStats; +import com.codeit.team5.mopl.content.entity.QContentTag; +import com.codeit.team5.mopl.binarycontent.entity.QBinaryContent; +import com.codeit.team5.mopl.tag.entity.QTag; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Repository; + +// TODO(통합 테스트): typeEqual/keywordLike/tagsIn 필터, createdAt/watcherCount/rate 커서 페이지네이션, +// hasNext 판별을 Testcontainers(PostgreSQL) 기반 @DataJpaTest로 검증 필요 +@Repository +@RequiredArgsConstructor +public class ContentQueryRepositoryImpl implements ContentQueryRepository { + + private final JPAQueryFactory queryFactory; + + private static final QContent content = QContent.content; + private static final QContentStats stats = QContentStats.contentStats; + private static final QContentTag contentTag = QContentTag.contentTag; + private static final QTag tag = QTag.tag; + private static final QBinaryContent thumbnail = QBinaryContent.binaryContent; + + @Override + public List findContents(ContentCursorRequest request, int fetchLimit) { + return queryFactory + .selectFrom(content) + .leftJoin(content.stats, stats).fetchJoin() + .leftJoin(content.thumbnail, thumbnail).fetchJoin() + .where(buildWhere(request)) + .orderBy(buildOrder(request)) + .limit(fetchLimit) + .fetch(); + } + + @Override + public long countContents(ContentCursorRequest request) { + BooleanBuilder where = new BooleanBuilder(); + applyFilters(where, request); + Long count = queryFactory + .select(content.count()) + .from(content) + .where(where) + .fetchOne(); + return count != null ? count : 0L; + } + + private BooleanBuilder buildWhere(ContentCursorRequest request) { + BooleanBuilder where = new BooleanBuilder(); + applyFilters(where, request); + applyCursor(where, request); + return where; + } + + private void applyFilters(BooleanBuilder where, ContentCursorRequest request) { + where.and(typeFilter(request.typeEqual())); + where.and(keywordFilter(request.keywordLike())); + where.and(tagsFilter(request.tagsIn())); + } + + private BooleanExpression typeFilter(ContentType typeEqual) { + return typeEqual != null ? content.type.eq(typeEqual) : null; + } + + private BooleanExpression keywordFilter(String keyword) { + if (keyword == null || keyword.isBlank()) return null; + return content.title.containsIgnoreCase(keyword) + .or(content.description.containsIgnoreCase(keyword)); + } + + private BooleanExpression tagsFilter(List tagsIn) { + if (tagsIn == null || tagsIn.isEmpty()) return null; + List normalizedTags = tagsIn.stream() + .map(String::trim) + .map(String::toLowerCase) + .toList(); + return content.id.in( + queryFactory.select(contentTag.content.id) + .from(contentTag) + .join(contentTag.tag, tag) + .where(tag.name.in(normalizedTags)) + ); + } + + private void applyCursor(BooleanBuilder where, ContentCursorRequest request) { + String cursor = request.cursor(); + String idAfter = request.idAfter(); + if (cursor == null || idAfter == null) { + return; + } + + UUID id = UUID.fromString(idAfter); + boolean isAsc = request.sortDirection() == Direction.ASC; + + BooleanExpression cursorCondition = switch (request.sortBy()) { + case CREATED_AT -> { + Instant cursorInstant = Instant.parse(cursor); + yield isAsc + ? content.createdAt.gt(cursorInstant) + .or(content.createdAt.eq(cursorInstant).and(content.id.gt(id))) + : content.createdAt.lt(cursorInstant) + .or(content.createdAt.eq(cursorInstant).and(content.id.lt(id))); + } + case WATCHER_COUNT -> { + long cursorVal = Long.parseLong(cursor); + yield isAsc + ? stats.watcherCount.gt(cursorVal) + .or(stats.watcherCount.eq(cursorVal).and(content.id.gt(id))) + : stats.watcherCount.lt(cursorVal) + .or(stats.watcherCount.eq(cursorVal).and(content.id.lt(id))); + } + case RATE -> { + double cursorVal = Double.parseDouble(cursor); + NumberExpression avgRating = averageRating(); + yield isAsc + ? avgRating.gt(cursorVal) + .or(avgRating.eq(cursorVal).and(content.id.gt(id))) + : avgRating.lt(cursorVal) + .or(avgRating.eq(cursorVal).and(content.id.lt(id))); + } + }; + where.and(cursorCondition); + } + + private NumberExpression averageRating() { + return new CaseBuilder() + .when(stats.reviewCount.eq(0)) + .then(0.0) + .otherwise(stats.ratingSum.divide(stats.reviewCount.doubleValue())); + } + + private OrderSpecifier[] buildOrder(ContentCursorRequest request) { + boolean isAsc = request.sortDirection() == Direction.ASC; + NumberExpression avgRating = averageRating(); + OrderSpecifier primary = switch (request.sortBy()) { + case CREATED_AT -> isAsc ? content.createdAt.asc() : content.createdAt.desc(); + case WATCHER_COUNT -> isAsc ? stats.watcherCount.asc() : stats.watcherCount.desc(); + case RATE -> isAsc ? avgRating.asc() : avgRating.desc(); + }; + OrderSpecifier secondary = isAsc ? content.id.asc() : content.id.desc(); + return new OrderSpecifier[]{ primary, secondary }; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index ccdf00d6..a831f9bb 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -1,25 +1,27 @@ package com.codeit.team5.mopl.content.service; -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; -import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; -import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.entity.ContentTag; import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.exception.EmptyTagException; import com.codeit.team5.mopl.content.exception.TooManyTagsException; import com.codeit.team5.mopl.content.mapper.ContentMapper; import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.repository.ContentStatsRepository; -import com.codeit.team5.mopl.content.entity.ContentTag; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; @@ -51,6 +53,8 @@ public class ContentService { private final BinaryContentRepository binaryContentRepository; private final ApplicationEventPublisher eventPublisher; + private static final String SECONDARY_SORT_FIELD = "id"; + @Transactional public ContentResponse create(ContentCreateRequest request, FileRequest image) { Content content = contentRepository.save(Content.createByAdmin( @@ -93,6 +97,21 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File return contentMapper.toDto(content); } + public ContentResponse findById(UUID contentId) { + Content content = contentRepository.findWithStatsAndTagsById(contentId) + .orElseThrow(() -> new ContentNotFoundException(contentId)); + return contentMapper.toDto(content); + } + + public CursorResponse findContents(ContentCursorRequest request) { + int fetchLimit = request.limit() + 1; + List fetched = contentRepository.findContents(request, fetchLimit); + boolean hasNext = fetched.size() > request.limit(); + List page = hasNext ? fetched.subList(0, request.limit()) : fetched; + long totalCount = contentRepository.countContents(request); + return contentMapper.toCursor(page, hasNext, totalCount, request.sortBy(), request.sortDirection()); + } + @Transactional public void delete(UUID contentId) { Content content = contentRepository.findWithStatsAndTagsById(contentId) @@ -166,4 +185,5 @@ private List normalizeTagNames(List rawTagNames) { if (tagNames.size() > 10) throw new TooManyTagsException(); return tagNames; } + } diff --git a/src/main/java/com/codeit/team5/mopl/global/exception/InvalidSortDirectionException.java b/src/main/java/com/codeit/team5/mopl/global/exception/InvalidSortDirectionException.java new file mode 100644 index 00000000..a1524776 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/exception/InvalidSortDirectionException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.global.exception; + +import org.springframework.http.HttpStatus; + +public class InvalidSortDirectionException extends BusinessException { + + public InvalidSortDirectionException(String value) { + super(HttpStatus.BAD_REQUEST, "정렬 방향 입력값이 올바르지 않습니다. (ASCENDING 또는 DESCENDING): " + value); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/notification/exception/InvalidCursorException.java b/src/main/java/com/codeit/team5/mopl/notification/exception/InvalidCursorException.java index e1ce1791..2b48aa16 100644 --- a/src/main/java/com/codeit/team5/mopl/notification/exception/InvalidCursorException.java +++ b/src/main/java/com/codeit/team5/mopl/notification/exception/InvalidCursorException.java @@ -5,7 +5,7 @@ public class InvalidCursorException extends BusinessException { - public InvalidCursorException(String cursor){ - super(HttpStatus.BAD_REQUEST, "커서 값이 유효하지 않습니다. cursor={" + cursor + "}"); + public InvalidCursorException(){ + super(HttpStatus.BAD_REQUEST, "커서 값이 유효하지 않습니다."); } } diff --git a/src/main/java/com/codeit/team5/mopl/notification/service/NotificationService.java b/src/main/java/com/codeit/team5/mopl/notification/service/NotificationService.java index 0b9540b6..ffebb8a0 100644 --- a/src/main/java/com/codeit/team5/mopl/notification/service/NotificationService.java +++ b/src/main/java/com/codeit/team5/mopl/notification/service/NotificationService.java @@ -65,7 +65,7 @@ public CursorResponseNotificationDto getNotifications( try{ cursorInstant = (cursor == null || cursor.isBlank()) ? null : Instant.parse(cursor); } catch (DateTimeParseException e){ - throw new InvalidCursorException(cursor); + throw new InvalidCursorException(); } // 다음 페이지 존재 여부를 알기 위해 limit보다 1개 더 조회 diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java index 07e968a2..c7f7c91a 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; 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.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -19,8 +20,10 @@ import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.service.ContentService; @@ -521,6 +524,171 @@ void patchContent_unexpectedException_returnsInternalServerError() throws Except .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); } + // --- GET (단건) --- + @Test + @DisplayName("존재하는 콘텐츠 ID로 조회하면 콘텐츠와 200 응답을 반환한다") + void getContent_success() throws Exception { + // Given + UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + ContentResponse response = new ContentResponse( + contentId, + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + "http://localhost:8080/thumbnails/test.jpg", + BinaryContentUploadStatus.PENDING, + List.of("액션", "SF"), + 4.5, 10, 100L + ); + + given(contentService.findById(contentId)).willReturn(response); + + // When & Then + mockMvc.perform(get("/api/contents/{contentId}", contentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(contentId.toString())) + .andExpect(jsonPath("$.type").value("movie")) + .andExpect(jsonPath("$.title").value("테스트 영화")) + .andExpect(jsonPath("$.description").value("테스트 설명")) + .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) + .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) + .andExpect(jsonPath("$.tags[0]").value("액션")) + .andExpect(jsonPath("$.tags[1]").value("SF")) + .andExpect(jsonPath("$.averageRating").value(4.5)) + .andExpect(jsonPath("$.reviewCount").value(10)) + .andExpect(jsonPath("$.watcherCount").value(100)); + + verify(contentService).findById(contentId); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 ID로 조회하면 404 응답을 반환한다") + void getContent_notFound_returnsNotFound() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + given(contentService.findById(contentId)).willThrow(new ContentNotFoundException(contentId)); + + // When & Then + mockMvc.perform(get("/api/contents/{contentId}", contentId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) + .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다.")); + + verify(contentService).findById(contentId); + } + + @Test + @DisplayName("단건 조회 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") + void getContent_unexpectedException_returnsInternalServerError() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + given(contentService.findById(contentId)).willThrow(new IllegalStateException("unexpected")); + + // When & Then + mockMvc.perform(get("/api/contents/{contentId}", contentId)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) + .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); + } + + // --- GET (목록) --- + @Test + @DisplayName("필수 파라미터만으로 콘텐츠 목록을 조회하면 200 응답을 반환한다") + void getContents_success() throws Exception { + // Given + ContentResponse item = new ContentResponse( + UUID.randomUUID(), + ContentType.MOVIE, + "테스트 영화", + "테스트 설명", + null, null, + List.of("액션"), + 0.0, 0, 0L + ); + CursorResponse response = new CursorResponse<>( + List.of(item), + null, null, false, 1L, + "createdAt", "DESCENDING" + ); + + given(contentService.findContents(any(ContentCursorRequest.class))).willReturn(response); + + // When & Then + mockMvc.perform(get("/api/contents") + .param("limit", "20") + .param("sortDirection", "DESCENDING") + .param("sortBy", "createdAt")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(item.id().toString())) + .andExpect(jsonPath("$.data[0].title").value("테스트 영화")) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.sortBy").value("createdAt")) + .andExpect(jsonPath("$.sortDirection").value("DESCENDING")); + + verify(contentService).findContents(any(ContentCursorRequest.class)); + } + + @Test + @DisplayName("모든 필터 파라미터를 포함한 콘텐츠 목록 조회 시 200 응답을 반환한다") + void getContents_withAllFilters_success() throws Exception { + // Given + CursorResponse response = new CursorResponse<>( + List.of(), null, null, false, 0L, "createdAt", "DESCENDING" + ); + + given(contentService.findContents(any(ContentCursorRequest.class))).willReturn(response); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ContentCursorRequest.class); + + // When & Then + mockMvc.perform(get("/api/contents") + .param("typeEqual", "movie") + .param("keywordLike", "어벤져스") + .param("tagsIn", "액션", "SF") + .param("limit", "20") + .param("sortDirection", "DESCENDING") + .param("sortBy", "createdAt")) + .andExpect(status().isOk()); + + verify(contentService).findContents(requestCaptor.capture()); + ContentCursorRequest captured = requestCaptor.getValue(); + assertThat(captured.typeEqual()).isEqualTo(ContentType.MOVIE); + assertThat(captured.keywordLike()).isEqualTo("어벤져스"); + assertThat(captured.tagsIn()).containsExactly("액션", "SF"); + assertThat(captured.limit()).isEqualTo(20); + } + + @Test + @DisplayName("limit이 누락되면 400 응답을 반환한다") + void getContents_missingLimit_returnsBadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/api/contents") + .param("sortDirection", "DESCENDING") + .param("sortBy", "createdAt")) + .andExpect(status().isBadRequest()); + + verify(contentService, never()).findContents(any()); + } + + @Test + @DisplayName("목록 조회 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") + void getContents_unexpectedException_returnsInternalServerError() throws Exception { + // Given + given(contentService.findContents(any(ContentCursorRequest.class))) + .willThrow(new IllegalStateException("unexpected")); + + // When & Then + mockMvc.perform(get("/api/contents") + .param("limit", "20") + .param("sortDirection", "DESCENDING") + .param("sortBy", "createdAt")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) + .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); + } + // --- DELETE --- @Test @DisplayName("정상적인 콘텐츠 삭제 요청이면 204 응답을 반환한다") diff --git a/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java new file mode 100644 index 00000000..0d474966 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java @@ -0,0 +1,109 @@ +package com.codeit.team5.mopl.content.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.codeit.team5.mopl.content.entity.ContentSortByType; +import jakarta.validation.ConstraintValidatorContext; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class ValidCursorValidatorTest { + + private ValidCursorValidator validator; + private ConstraintValidatorContext context; + + @BeforeEach + void setUp() { + validator = new ValidCursorValidator(); + context = mock(ConstraintValidatorContext.class); + } + + private ContentCursorRequest request(String cursor, String idAfter, ContentSortByType sortBy) { + return new ContentCursorRequest(null, null, null, cursor, idAfter, 20, Sort.Direction.DESC, sortBy); + } + + // --- 커서 없음 (첫 페이지) --- + @Test + @DisplayName("cursor와 idAfter 모두 null이면 유효하다") + void valid_bothNull() { + assertThat(validator.isValid(request(null, null, ContentSortByType.CREATED_AT), context)).isTrue(); + } + + // --- pair 검증 --- + @Test + @DisplayName("cursor만 있고 idAfter가 null이면 유효하지 않다") + void invalid_onlyCursor() { + assertThat(validator.isValid(request(Instant.now().toString(), null, ContentSortByType.CREATED_AT), context)).isFalse(); + } + + @Test + @DisplayName("idAfter만 있고 cursor가 null이면 유효하지 않다") + void invalid_onlyIdAfter() { + assertThat(validator.isValid(request(null, UUID.randomUUID().toString(), ContentSortByType.CREATED_AT), context)).isFalse(); + } + + // --- idAfter UUID 검증 --- + @Test + @DisplayName("idAfter가 UUID 형식이 아니면 유효하지 않다") + void invalid_idAfterNotUuid() { + assertThat(validator.isValid(request(Instant.now().toString(), "not-a-uuid", ContentSortByType.CREATED_AT), context)).isFalse(); + } + + // --- CREATED_AT cursor 검증 --- + @Test + @DisplayName("CREATED_AT 정렬에서 유효한 ISO-8601 cursor는 유효하다") + void valid_createdAt_cursor() { + String cursor = Instant.now().toString(); + String idAfter = UUID.randomUUID().toString(); + assertThat(validator.isValid(request(cursor, idAfter, ContentSortByType.CREATED_AT), context)).isTrue(); + } + + @Test + @DisplayName("CREATED_AT 정렬에서 날짜 형식이 아닌 cursor는 유효하지 않다") + void invalid_createdAt_cursor() { + assertThat(validator.isValid(request("not-a-date", UUID.randomUUID().toString(), ContentSortByType.CREATED_AT), context)).isFalse(); + } + + // --- WATCHER_COUNT cursor 검증 --- + @Test + @DisplayName("WATCHER_COUNT 정렬에서 유효한 숫자 cursor는 유효하다") + void valid_watcherCount_cursor() { + assertThat(validator.isValid(request("100", UUID.randomUUID().toString(), ContentSortByType.WATCHER_COUNT), context)).isTrue(); + } + + @Test + @DisplayName("WATCHER_COUNT 정렬에서 숫자가 아닌 cursor는 유효하지 않다") + void invalid_watcherCount_cursor() { + assertThat(validator.isValid(request("abc", UUID.randomUUID().toString(), ContentSortByType.WATCHER_COUNT), context)).isFalse(); + } + + // --- RATE cursor 검증 --- + @Test + @DisplayName("RATE 정렬에서 유효한 소수 cursor는 유효하다") + void valid_rate_cursor() { + assertThat(validator.isValid(request("4.5", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isTrue(); + } + + @Test + @DisplayName("RATE 정렬에서 숫자가 아닌 cursor는 유효하지 않다") + void invalid_rate_cursor_notNumber() { + assertThat(validator.isValid(request("abc", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); + } + + @Test + @DisplayName("RATE 정렬에서 NaN cursor는 유효하지 않다") + void invalid_rate_cursor_nan() { + assertThat(validator.isValid(request("NaN", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); + } + + @Test + @DisplayName("RATE 정렬에서 Infinity cursor는 유효하지 않다") + void invalid_rate_cursor_infinity() { + assertThat(validator.isValid(request("Infinity", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index dd3df43f..0b1c0975 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -20,8 +20,11 @@ import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.ContentSortByType; +import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.content.entity.ContentStats; import com.codeit.team5.mopl.content.entity.ContentTag; @@ -33,11 +36,13 @@ import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -407,6 +412,135 @@ contentId, new ContentUpdateRequest("수정 제목", null, List.of(" ", " ")), verifyNoInteractions(binaryContentRepository, eventPublisher, contentMapper); } + // --- FIND BY ID --- + @Test + @DisplayName("존재하는 콘텐츠 ID로 단건 조회에 성공한다") + void findById_success() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "테스트 영화", "설명"); + ContentResponse expectedResponse = new ContentResponse( + contentId, ContentType.MOVIE, "테스트 영화", "설명", + null, null, List.of("액션"), 4.5, 10, 100L + ); + + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + when(contentMapper.toDto(content)).thenReturn(expectedResponse); + + // when + ContentResponse result = contentService.findById(contentId); + + // then + assertThat(result).isSameAs(expectedResponse); + verify(contentRepository).findWithStatsAndTagsById(contentId); + verify(contentMapper).toDto(content); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 ID로 단건 조회 시 예외를 던진다") + void findById_notFound_throwsException() { + // given + UUID contentId = UUID.randomUUID(); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> contentService.findById(contentId)) + .isInstanceOf(ContentNotFoundException.class); + + verifyNoInteractions(contentMapper); + } + + // --- FIND CONTENTS --- + @Test + @DisplayName("다음 페이지가 없는 경우 hasNext=false로 커서 응답을 반환한다") + void findContents_noNextPage_success() { + // given + ContentCursorRequest request = new ContentCursorRequest( + null, null, null, null, null, + 20, Sort.Direction.DESC, ContentSortByType.CREATED_AT + ); + List contents = List.of( + Content.createByAdmin(ContentType.MOVIE, "영화1", null), + Content.createByAdmin(ContentType.MOVIE, "영화2", null) + ); + CursorResponse expectedResponse = new CursorResponse<>( + List.of(), null, null, false, 2L, "createdAt", "DESCENDING" + ); + + when(contentRepository.findContents(request, 21)).thenReturn(contents); + when(contentRepository.countContents(request)).thenReturn(2L); + when(contentMapper.toCursor(contents, false, 2L, ContentSortByType.CREATED_AT, Sort.Direction.DESC)) + .thenReturn(expectedResponse); + + // when + CursorResponse result = contentService.findContents(request); + + // then + assertThat(result).isSameAs(expectedResponse); + verify(contentRepository).findContents(request, 21); + verify(contentRepository).countContents(request); + verify(contentMapper).toCursor(contents, false, 2L, ContentSortByType.CREATED_AT, Sort.Direction.DESC); + } + + @Test + @DisplayName("다음 페이지가 있는 경우 hasNext=true이고 limit개만 반환한다") + void findContents_hasNextPage_success() { + // given + int limit = 2; + ContentCursorRequest request = new ContentCursorRequest( + null, null, null, null, null, + limit, Sort.Direction.DESC, ContentSortByType.CREATED_AT + ); + // limit+1 = 3개 반환 → hasNext=true + List fetched = new ArrayList<>(); + for (int i = 0; i < limit + 1; i++) { + fetched.add(Content.createByAdmin(ContentType.MOVIE, "영화" + i, null)); + } + List page = fetched.subList(0, limit); + CursorResponse expectedResponse = new CursorResponse<>( + List.of(), "cursor", "idAfter", true, 5L, "createdAt", "DESCENDING" + ); + + when(contentRepository.findContents(request, limit + 1)).thenReturn(fetched); + when(contentRepository.countContents(request)).thenReturn(5L); + when(contentMapper.toCursor(page, true, 5L, ContentSortByType.CREATED_AT, Sort.Direction.DESC)) + .thenReturn(expectedResponse); + + // when + CursorResponse result = contentService.findContents(request); + + // then + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo("cursor"); + verify(contentMapper).toCursor(page, true, 5L, ContentSortByType.CREATED_AT, Sort.Direction.DESC); + } + + @Test + @DisplayName("조회 결과가 없으면 빈 목록과 totalCount=0을 반환한다") + void findContents_emptyResult() { + // given + ContentCursorRequest request = new ContentCursorRequest( + ContentType.MOVIE, "존재하지않는키워드", null, null, null, + 20, Sort.Direction.DESC, ContentSortByType.CREATED_AT + ); + CursorResponse expectedResponse = new CursorResponse<>( + List.of(), null, null, false, 0L, "createdAt", "DESCENDING" + ); + + when(contentRepository.findContents(request, 21)).thenReturn(List.of()); + when(contentRepository.countContents(request)).thenReturn(0L); + when(contentMapper.toCursor(List.of(), false, 0L, ContentSortByType.CREATED_AT, Sort.Direction.DESC)) + .thenReturn(expectedResponse); + + // when + CursorResponse result = contentService.findContents(request); + + // then + assertThat(result.data()).isEmpty(); + assertThat(result.totalCount()).isZero(); + assertThat(result.hasNext()).isFalse(); + } + // --- DELETE --- @Test @DisplayName("썸네일 없는 콘텐츠 삭제에 성공한다") diff --git a/src/test/java/com/codeit/team5/mopl/follow/repository/FollowRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/follow/repository/FollowRepositoryTest.java index d5b24275..a7d357df 100644 --- a/src/test/java/com/codeit/team5/mopl/follow/repository/FollowRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/follow/repository/FollowRepositoryTest.java @@ -8,6 +8,7 @@ import com.codeit.team5.mopl.config.JpaAuditingConfig; import com.codeit.team5.mopl.follow.entity.Follow; import com.codeit.team5.mopl.follow.exception.SelfFollowException; +import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; import com.codeit.team5.mopl.user.entity.User; import jakarta.persistence.EntityManager; import java.util.Optional; @@ -24,7 +25,7 @@ @DataJpaTest @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = NONE) -@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class}) +@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class, QueryDslTestConfig.class}) class FollowRepositoryTest { @Autowired diff --git a/src/test/java/com/codeit/team5/mopl/global/support/base/BaseRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/global/support/base/BaseRepositoryTest.java index 0396b7ad..14957ecf 100644 --- a/src/test/java/com/codeit/team5/mopl/global/support/base/BaseRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/global/support/base/BaseRepositoryTest.java @@ -2,6 +2,7 @@ import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.global.support.config.HibernateConfig; +import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; import com.codeit.team5.mopl.global.support.inspector.QueryInspector; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -14,7 +15,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@Import({HibernateConfig.class, QueryInspector.class, TestcontainersConfiguration.class}) +@Import({HibernateConfig.class, QueryInspector.class, TestcontainersConfiguration.class, QueryDslTestConfig.class}) @DataJpaTest @EnableJpaAuditing @AutoConfigureTestDatabase(replace = Replace.NONE) diff --git a/src/test/java/com/codeit/team5/mopl/global/support/config/QueryDslTestConfig.java b/src/test/java/com/codeit/team5/mopl/global/support/config/QueryDslTestConfig.java new file mode 100644 index 00000000..ef95f95c --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/global/support/config/QueryDslTestConfig.java @@ -0,0 +1,15 @@ +package com.codeit.team5.mopl.global.support.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class QueryDslTestConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager em) { + return new JPAQueryFactory(em); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java index be7f2e8c..7d4e4766 100644 --- a/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/notification/repository/NotificationRepositoryTest.java @@ -6,6 +6,7 @@ import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.config.JpaAuditingConfig; +import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; import com.codeit.team5.mopl.notification.entity.Notification; import com.codeit.team5.mopl.notification.entity.NotificationLevel; import com.codeit.team5.mopl.notification.entity.NotificationType; @@ -30,7 +31,7 @@ @DataJpaTest @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = NONE) -@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class}) +@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class, QueryDslTestConfig.class}) class NotificationRepositoryTest { @Autowired diff --git a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java index e74b0759..113bb0a5 100644 --- a/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/repository/UserRepositoryTest.java @@ -6,6 +6,7 @@ import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.config.JpaAuditingConfig; +import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; import com.codeit.team5.mopl.user.entity.User; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; @@ -23,7 +24,7 @@ @DataJpaTest @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = NONE) -@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class}) +@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class, QueryDslTestConfig.class}) class UserRepositoryTest { @Autowired