diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java index 8f0225d7..1ac92ebf 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java @@ -3,5 +3,6 @@ public enum BinaryContentUploadStatus { PENDING, COMPLETED, - FAILED + FAILED, + DELETED } 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 8a5a4d49..67863631 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 @@ -2,15 +2,20 @@ 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.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.service.ContentService; import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; import jakarta.validation.Valid; +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.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -35,4 +40,24 @@ public ResponseEntity postContent( ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + @Override + @PatchMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity patchContent( + @PathVariable UUID contentId, + @Valid @RequestPart("request") ContentUpdateRequest request, + @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail + ) { + log.info("Content Update request: PATCH /api/contents/{}", contentId); + ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail)); + return ResponseEntity.ok(response); + } + + @Override + @DeleteMapping("/{contentId}") + public ResponseEntity deleteContent(@PathVariable UUID contentId) { + log.info("Content Delete request: DELETE /api/contents/{}", contentId); + contentService.delete(contentId); + return ResponseEntity.noContent().build(); + } } 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 fa59c6ce..a7fbb770 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,8 +1,9 @@ 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.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; -import com.codeit.team5.mopl.global.dto.ErrorResponse; +import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -12,6 +13,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 java.util.UUID; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; @@ -20,7 +22,7 @@ public interface ContentApi { @Schema(name = "ContentCreateMultipartRequest") - class MultipartRequest { + class ContentCreateMultipartRequest { @Schema(implementation = ContentCreateRequest.class) public ContentCreateRequest request; @@ -33,21 +35,72 @@ class MultipartRequest { @ApiResponse(responseCode = "201", description = "성공", content = @Content(schema = @Schema(implementation = ContentResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "401", description = "인증 오류", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "403", description = "권한 오류", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "500", description = "서버 오류", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) @RequestBody(content = @Content( mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, - schema = @Schema(implementation = MultipartRequest.class), + schema = @Schema(implementation = ContentCreateMultipartRequest.class), encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) )) ResponseEntity postContent( @Parameter(hidden = true) ContentCreateRequest request, @Parameter(hidden = true) MultipartFile thumbnail ); + + @Schema(name = "ContentUpdateMultipartRequest") + class ContentUpdateMultipartRequest { + @Schema(implementation = ContentUpdateRequest.class) + public ContentCreateRequest request; + + @Schema(type = "string", format = "binary", description = "썸네일 이미지") + public MultipartFile thumbnail; + } + + @Operation(summary = "[어드민] 콘텐츠 수정", description = "콘텐츠의 제목, 설명, 태그를 수정합니다.") + @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 = "403", 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))) + }) + @RequestBody(content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = ContentUpdateMultipartRequest.class), + encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) + )) + ResponseEntity patchContent( + @Parameter(description = "콘텐츠 ID") UUID contentId, + @Parameter(hidden = true) ContentUpdateRequest request, + @Parameter(hidden = true) MultipartFile thumbnail + ); + + @Operation(summary = "[어드민] 콘텐츠 삭제", description = "콘텐츠를 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", 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 = "403", 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 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/ContentCreateRequest.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java index 09e8c4ed..258486c5 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java @@ -19,6 +19,7 @@ public record ContentCreateRequest( @NotNull(message = "콘텐츠 태그는 필수 입니다.") @NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.") + @Size(max = 10, message = "태그는 최대 10개까지 등록할 수 있습니다.") List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags ) { } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentUpdateRequest.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentUpdateRequest.java new file mode 100644 index 00000000..1ff5e337 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentUpdateRequest.java @@ -0,0 +1,21 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ContentUpdateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 500, message = "제목은 500자를 초과할 수 없습니다.") + String title, + + String description, + + @NotNull(message = "콘텐츠 태그는 필수입니다.") + @NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.") + @Size(max = 10, message = "태그는 최대 10개까지 등록할 수 있습니다.") + List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags +) { +} 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 ecbc4b03..e191fe43 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 @@ -2,6 +2,7 @@ import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; +import com.codeit.team5.mopl.content.exception.InvalidContentTitleException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -23,6 +24,7 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import org.springframework.util.StringUtils; @Getter @Entity @@ -116,4 +118,12 @@ public void addTag(ContentTag contentTag) { } contentTags.add(contentTag); } + + public void update(String title, String description) { + if (!StringUtils.hasText(title)) { + throw new InvalidContentTitleException(); + } + this.title = title; + this.description = description; + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java b/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java index cac031b3..b0546bad 100644 --- a/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java +++ b/src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java @@ -1,11 +1,16 @@ package com.codeit.team5.mopl.content.exception; import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; import org.springframework.http.HttpStatus; -public class ContentException extends BusinessException { +public abstract class ContentException extends BusinessException { public ContentException(HttpStatus status, String message) { super(status, message); } + + public ContentException(HttpStatus status, String message, Map details) { + super(status, message, details); + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java b/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java index 1d6cf243..fca28e84 100644 --- a/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java +++ b/src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java @@ -1,10 +1,12 @@ package com.codeit.team5.mopl.content.exception; +import java.util.Map; +import java.util.UUID; import org.springframework.http.HttpStatus; public class ContentNotFoundException extends ContentException { - public ContentNotFoundException() { - super(HttpStatus.NOT_FOUND, "콘텐츠를 찾을 수 없습니다."); + public ContentNotFoundException(UUID contentId) { + super(HttpStatus.NOT_FOUND, "콘텐츠를 찾을 수 없습니다.", Map.of("contentId", contentId)); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTitleException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTitleException.java new file mode 100644 index 00000000..8b41c81a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTitleException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import org.springframework.http.HttpStatus; + +public class InvalidContentTitleException extends ContentException { + + public InvalidContentTitleException() { + super(HttpStatus.BAD_REQUEST, "제목은 필수입니다."); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/TooManyTagsException.java b/src/main/java/com/codeit/team5/mopl/content/exception/TooManyTagsException.java new file mode 100644 index 00000000..7f8d09fb --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/TooManyTagsException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import org.springframework.http.HttpStatus; + +public class TooManyTagsException extends ContentException { + + public TooManyTagsException() { + super(HttpStatus.BAD_REQUEST, "태그는 최대 10개까지 등록할 수 있습니다."); + } +} 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 1cac1a65..df544458 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 @@ -8,6 +8,6 @@ public interface ContentRepository extends JpaRepository { - @EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags"}) + @EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"}) Optional findWithStatsAndTagsById(UUID id); } 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 128baa14..ccdf00d6 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 @@ -5,13 +5,17 @@ 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.content.dto.request.ContentCreateRequest; +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.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; @@ -19,8 +23,11 @@ import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -45,20 +52,15 @@ public class ContentService { private final ApplicationEventPublisher eventPublisher; @Transactional - public ContentResponse create(ContentCreateRequest request, FileRequest thumbnail) { + public ContentResponse create(ContentCreateRequest request, FileRequest image) { Content content = contentRepository.save(Content.createByAdmin( request.type(), request.title(), request.description() )); - if (thumbnail != null) { - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, content.getId(), thumbnail.filename()); - BinaryContent binaryContent = binaryContentRepository.save( - BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); - content.attachThumbnail(binaryContent); - eventPublisher.publishEvent(new BinaryContentUploadEvent( - binaryContent.getId(), generated.key(), thumbnail.bytes(), generated.contentType())); + if (image != null) { + content.attachThumbnail(storeThumbnailImage(content.getId(), image)); } attachTags(content, request.tags()); @@ -69,18 +71,74 @@ public ContentResponse create(ContentCreateRequest request, FileRequest thumbnai return contentMapper.toDto(content); } + @Transactional + public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) { + Content content = contentRepository.findWithStatsAndTagsById(contentId) + .orElseThrow(() -> new ContentNotFoundException(contentId)); + + List tagNames = normalizeTagNames(request.tags()); + + content.update(request.title(), request.description()); + updateTags(content, tagNames); + + if (image != null) { + // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) + BinaryContent oldThumbnail = content.getThumbnail(); + if (oldThumbnail != null) { + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); + } + content.attachThumbnail(storeThumbnailImage(content.getId(), image)); + } + + return contentMapper.toDto(content); + } + + @Transactional + public void delete(UUID contentId) { + Content content = contentRepository.findWithStatsAndTagsById(contentId) + .orElseThrow(() -> new ContentNotFoundException(contentId)); + BinaryContent oldThumbnail = content.getThumbnail(); + if (oldThumbnail != null) { + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); + } + contentRepository.delete(content); + } + + private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) { + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename()); + BinaryContent thumbnailImage = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); + + eventPublisher.publishEvent( + new BinaryContentUploadEvent(thumbnailImage.getId(), generated.key(), image.bytes(), generated.contentType())); + // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 썸네일 이미지 롤백 + + return thumbnailImage; + } + private void attachTags(Content content, List rawTagNames) { - List tagNames = rawTagNames.stream() - .map(String::trim) - .filter(name -> !name.isEmpty()) - .map(String::toLowerCase) - .distinct() + List tagNames = normalizeTagNames(rawTagNames); + insertTags(content, tagNames); + } + + private void updateTags(Content content, List requestedNames) { + Set currentNames = content.getContentTags().stream() + .map(ct -> ct.getTag().getName()) + .collect(Collectors.toSet()); + + Set requestedSet = new HashSet<>(requestedNames); + content.getContentTags().removeIf(ct -> !requestedSet.contains(ct.getTag().getName())); + + List toAdd = requestedNames.stream() + .filter(name -> !currentNames.contains(name)) .toList(); - if (tagNames.isEmpty()) { - throw new EmptyTagException(); + if (!toAdd.isEmpty()) { + insertTags(content, toAdd); } + } + private void insertTags(Content content, List tagNames) { Map existingTags = tagRepository.findByNameIn(tagNames).stream() .collect(Collectors.toMap(Tag::getName, Function.identity())); @@ -95,4 +153,17 @@ private void attachTags(Content content, List rawTagNames) { tagNames.forEach(name -> content.addTag(ContentTag.create(content, existingTags.get(name)))); } + + private List normalizeTagNames(List rawTagNames) { + List tagNames = rawTagNames.stream() + .map(String::trim) + .filter(name -> !name.isEmpty()) + .map(String::toLowerCase) + .distinct() + .toList(); + + if (tagNames.isEmpty()) throw new EmptyTagException(); + if (tagNames.size() > 10) throw new TooManyTagsException(); + return tagNames; + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java index 3bc96dcc..9a88f9ce 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java @@ -4,6 +4,7 @@ import jakarta.persistence.LockModeType; import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -18,4 +19,8 @@ public interface UserRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select u from User u where u.id = :id") Optional findByIdForUpdate(@Param("id") UUID id); + + // N+1 문제 방지 update용 조회 메서드 + @EntityGraph(attributePaths = {"profileImage"}) + Optional findWithProfileImageById(UUID id); } diff --git a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java index 6a0f5e60..356a047a 100644 --- a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java +++ b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.user.service; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; @@ -69,12 +70,21 @@ public UserResponse getById(UUID userId) { @Transactional public UserResponse update(UUID userId, UserUpdateRequest request, FileRequest image) { - User user = getUser(userId); + User user = userRepository.findWithProfileImageById(userId) + .orElseThrow(() -> { + log.warn("User not found: userId={}", userId); + return new UserNotFoundException(userId); + }); // TODO: 인증 구현 후 본인 확인(현재 로그인 사용자 == userId) 추가, 불일치 시 403 user.updateName(request.name()); if (image != null) { + // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) + BinaryContent oldProfile = user.getProfileImage(); + if (oldProfile != null) { + oldProfile.updateUploadStatus(BinaryContentUploadStatus.DELETED); + } user.updateProfileImage(storeProfileImage(user.getId(), image)); } @@ -83,7 +93,6 @@ public UserResponse update(UUID userId, UserUpdateRequest request, FileRequest i } private BinaryContent storeProfileImage(UUID userId, FileRequest image) { - // TODO(고아 정리): 기존 profileImage를 삭제 대상 상태로 표시 후 배치로 정리 (DB/S3 누적 방지) GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, userId, image.filename()); BinaryContent profileImage = binaryContentRepository.save( BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); diff --git a/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java b/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java index e1497904..74023635 100644 --- a/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java +++ b/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java @@ -14,14 +14,14 @@ public interface WatchingSessionRepository extends JpaRepository { - @EntityGraph(attributePaths = {"user", "content", "content.stats"}) + @EntityGraph(attributePaths = {"user", "content", "content.thumbnail", "content.stats", "content.contentTags", "content.contentTags.tag"}) Optional findByUserId(UUID userId); @Modifying @Query("DELETE FROM WatchingSession w WHERE w.user.id = :userId") void deleteByUserIdDirectly(UUID userId); - @EntityGraph(attributePaths = {"user", "content", "content.stats"}) + @EntityGraph(attributePaths = {"user", "content", "content.thumbnail", "content.stats"}) Window findByContentId(UUID contentId, ScrollPosition position, Limit limit, Sort sort); diff --git a/src/main/resources/db/migration/V11__add_deleted_status_to_binary_contents.sql b/src/main/resources/db/migration/V11__add_deleted_status_to_binary_contents.sql new file mode 100644 index 00000000..3abad823 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_deleted_status_to_binary_contents.sql @@ -0,0 +1,6 @@ +ALTER TABLE binary_contents + DROP CONSTRAINT ck_binary_contents_upload_status; + +ALTER TABLE binary_contents + ADD CONSTRAINT ck_binary_contents_upload_status + CHECK (upload_status IN ('PENDING', 'COMPLETED', 'FAILED', 'DELETED')); 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 449367a5..e93d2db6 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 @@ -2,20 +2,28 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.hamcrest.Matchers.hasItem; +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.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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.ContentUpdateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.service.ContentService; +import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -30,11 +38,11 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import com.codeit.team5.mopl.global.dto.FileRequest; @WebMvcTest( controllers = ContentController.class, @@ -59,6 +67,8 @@ class ContentControllerTest { @Captor private ArgumentCaptor requestCaptor; + + // --- POST --- @Test @DisplayName("정상적인 콘텐츠 생성 요청이면 생성된 콘텐츠와 201 응답을 반환한다") void postContent_success() throws Exception { @@ -273,4 +283,285 @@ void postContent_unexpectedException_returnsInternalServerError() throws Excepti .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); } + + // --- PATCH --- + @Test + @DisplayName("정상적인 콘텐츠 수정 요청이면 수정된 콘텐츠와 200 응답을 반환한다") + void patchContent_success() throws Exception { + // Given + UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + ContentUpdateRequest request = new ContentUpdateRequest( + "수정된 영화", + "수정된 설명", + List.of("액션", "SF") + ); + ContentResponse response = new ContentResponse( + contentId, + ContentType.MOVIE, + "수정된 영화", + "수정된 설명", + "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/new.jpg", + BinaryContentUploadStatus.PENDING, + List.of("SF", "액션"), + 0.0, 0, 0 + ); + + given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + .willReturn(response); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + MockMultipartFile thumbnailPart = new MockMultipartFile( + "thumbnail", "new.jpg", MediaType.IMAGE_JPEG_VALUE, + new byte[]{4, 5, 6} + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart) + .file(thumbnailPart)) + .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("$.averageRating").value(0.0)) + .andExpect(jsonPath("$.reviewCount").value(0)) + .andExpect(jsonPath("$.watcherCount").value(0)); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ContentUpdateRequest.class); + ArgumentCaptor thumbnailCaptor = ArgumentCaptor.forClass(FileRequest.class); + verify(contentService).update(eq(contentId), requestCaptor.capture(), thumbnailCaptor.capture()); + assertThat(requestCaptor.getValue().title()).isEqualTo("수정된 영화"); + assertThat(requestCaptor.getValue().description()).isEqualTo("수정된 설명"); + assertThat(requestCaptor.getValue().tags()).containsExactly("액션", "SF"); + assertThat(thumbnailCaptor.getValue().filename()).isEqualTo("new.jpg"); + assertThat(thumbnailCaptor.getValue().bytes()).containsExactly(4, 5, 6); + } + + @Test + @DisplayName("thumbnail 없이 콘텐츠 수정 요청이면 200 응답을 반환한다") + void patchContent_withoutThumbnail_success() throws Exception { + // Given + UUID contentId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + ContentUpdateRequest request = new ContentUpdateRequest( + "수정된 드라마", + null, + List.of("로맨스") + ); + ContentResponse response = new ContentResponse( + contentId, + ContentType.TV_SERIES, + "수정된 드라마", + null, + null, + null, + List.of("로맨스"), + 0.0, 0, 0 + ); + + given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), isNull())) + .willReturn(response); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(contentId.toString())) + .andExpect(jsonPath("$.title").value("수정된 드라마")) + .andExpect(jsonPath("$.tags[0]").value("로맨스")); + + verify(contentService).update(eq(contentId), any(ContentUpdateRequest.class), isNull()); + } + + @Test + @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") + void patchContent_blankTitle_returnsBadRequest() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + ContentUpdateRequest request = new ContentUpdateRequest(" ", null, List.of("액션")); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); + + verify(contentService, never()).update(any(), any(), any()); + } + + @Test + @DisplayName("태그가 null이면 400 검증 실패 응답을 반환한다") + void patchContent_nullTags_returnsBadRequest() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + String requestJson = """ + { + "title": "수정된 영화" + } + """; + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes() + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.tags", hasItem("콘텐츠 태그는 필수입니다."))); + + verify(contentService, never()).update(any(), any(), any()); + } + + @Test + @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") + void patchContent_emptyTags_returnsBadRequest() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of()); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); + + verify(contentService, never()).update(any(), any(), any()); + } + + @Test + @DisplayName("태그 항목이 공백이면 400 검증 실패 응답을 반환한다") + void patchContent_blankTagItem_returnsBadRequest() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of(" ")); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")) + .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) + .andExpect(jsonPath("$.details['tags[0]'][0]").value("태그는 공백일 수 없습니다.")); + + verify(contentService, never()).update(any(), any(), any()); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 수정 요청이면 404 응답을 반환한다") + void patchContent_notFound_returnsNotFound() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); + given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + .willThrow(new ContentNotFoundException(contentId)); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) + .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다.")); + + verify(contentService).update(eq(contentId), any(ContentUpdateRequest.class), any()); + } + + @Test + @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") + void patchContent_unexpectedException_returnsInternalServerError() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); + given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + .willThrow(new IllegalStateException("unexpected")); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) + .file(requestPart)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) + .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); + } + + // --- DELETE --- + @Test + @DisplayName("정상적인 콘텐츠 삭제 요청이면 204 응답을 반환한다") + void deleteContent_success() throws Exception { + // Given + UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + doNothing().when(contentService).delete(contentId); + + // When & Then + mockMvc.perform(delete("/api/contents/{contentId}", contentId)) + .andExpect(status().isNoContent()); + + verify(contentService).delete(contentId); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 삭제 요청이면 404 응답을 반환한다") + void deleteContent_notFound_returnsNotFound() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + doThrow(new ContentNotFoundException(contentId)).when(contentService).delete(contentId); + + // When & Then + mockMvc.perform(delete("/api/contents/{contentId}", contentId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) + .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다.")); + + verify(contentService).delete(contentId); + } + + @Test + @DisplayName("삭제 중 예상하지 못한 예외가 발생하면 500 오류 응답을 반환한다") + void deleteContent_unexpectedException_returnsInternalServerError() throws Exception { + // Given + UUID contentId = UUID.randomUUID(); + doThrow(new IllegalStateException("unexpected")).when(contentService).delete(contentId); + + // When & Then + mockMvc.perform(delete("/api/contents/{contentId}", contentId)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) + .andExpect(jsonPath("$.message").value("서버 내부 에러가 발생했습니다.")); + } } diff --git a/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java new file mode 100644 index 00000000..73147a9e --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java @@ -0,0 +1,127 @@ +package com.codeit.team5.mopl.content.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.entity.ContentTag; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.global.support.base.BaseRepositoryTest; +import com.codeit.team5.mopl.tag.entity.Tag; +import java.util.Optional; +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.beans.factory.annotation.Autowired; + +class ContentRepositoryTest extends BaseRepositoryTest { + + @Autowired + private ContentRepository contentRepository; + + @BeforeEach + void setUp() { + clear(); + } + + @Test + @DisplayName("stats, contentTags, tag를 1번 쿼리로 fetch join 조회_성공") + void findWithStatsAndTagsById_성공() { + // given + Content content = createContent(); + ContentStats stats = persistAndFlush(ContentStats.create()); + content.attachStats(stats); + + Tag tag1 = persistAndFlush(Tag.create("액션")); + Tag tag2 = persistAndFlush(Tag.create("드라마")); + content.addTag(ContentTag.create(content, tag1)); + content.addTag(ContentTag.create(content, tag2)); + flush(); + clear(); + + // when + Optional result = contentRepository.findWithStatsAndTagsById(content.getId()); + + // then + assertThat(result).isPresent(); + Content found = result.get(); + assertThat(found.getStats()).isNotNull(); + assertThat(found.getContentTags()).hasSize(2); + assertThat(found.getContentTags()) + .extracting(ct -> ct.getTag().getName()) + .containsExactlyInAnyOrder("액션", "드라마"); + ensureQueryCount(1); + } + + @Test + @DisplayName("썸네일이 있을 때 썸네일도 1번 쿼리로 함께 조회_성공") + void findWithStatsAndTagsById_썸네일포함_성공() { + // given + Content content = createContent(); + ContentStats stats = persistAndFlush(ContentStats.create()); + content.attachStats(stats); + + BinaryContent thumbnail = persistAndFlush(BinaryContent.pending("https://example.com/thumb.jpg")); + content.attachThumbnail(thumbnail); + flush(); + clear(); + + // when + Optional result = contentRepository.findWithStatsAndTagsById(content.getId()); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getThumbnail()).isNotNull(); + assertThat(result.get().getThumbnail().getUrl()).isEqualTo("https://example.com/thumb.jpg"); + ensureQueryCount(1); + } + + @Test + @DisplayName("태그가 없는 콘텐츠도 정상 조회_성공") + void findWithStatsAndTagsById_태그없음_성공() { + // given + Content content = createContent(); + ContentStats stats = persistAndFlush(ContentStats.create()); + content.attachStats(stats); + flush(); + clear(); + + // when + Optional result = contentRepository.findWithStatsAndTagsById(content.getId()); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getContentTags()).isEmpty(); + ensureQueryCount(1); + } + + @Test + @DisplayName("존재하지 않는 ID로 조회하면 빈 Optional 반환") + void findWithStatsAndTagsById_NotFound() { + // given + UUID randomId = UUID.randomUUID(); + + // when + Optional result = contentRepository.findWithStatsAndTagsById(randomId); + + // then + assertThat(result).isEmpty(); + ensureQueryCount(1); + } + + private Content createContent() { + Content content = Content.createByExternalSource( + ContentType.MOVIE, + "테스트 콘텐츠", + null, + ContentSource.TMDB, + "ext-id-" + UUID.randomUUID(), + null, + null + ); + return persistAndFlush(content); + } +} 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 7e39ea29..dd3df43f 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 @@ -18,11 +18,15 @@ import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; 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.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.entity.ContentType; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; import com.codeit.team5.mopl.content.exception.EmptyTagException; import com.codeit.team5.mopl.content.mapper.ContentMapper; import com.codeit.team5.mopl.content.repository.ContentRepository; @@ -30,7 +34,11 @@ import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.test.util.ReflectionTestUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -71,6 +79,7 @@ class ContentServiceTest { @InjectMocks private ContentService contentService; + // --- CREATE --- @Test @DisplayName("콘텐츠 생성에 성공한다") void create_success() { @@ -242,4 +251,214 @@ void create_tagsNormalized() { // then verify(tagRepository).findByNameIn(List.of("action", "drama")); } + + // --- UPDATE --- + @Test + @DisplayName("썸네일 없이 콘텐츠 수정에 성공한다") + void update_withoutThumbnail_success() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", "기존 설명"); + ContentUpdateRequest request = new ContentUpdateRequest("수정 제목", "수정 설명", List.of("SF")); + ContentResponse expectedResponse = new ContentResponse( + contentId, ContentType.MOVIE, "수정 제목", "수정 설명", + null, null, List.of("sf"), 0.0, 0, 0 + ); + + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + when(tagRepository.findByNameIn(List.of("sf"))).thenReturn(List.of(Tag.create("sf"))); + when(contentMapper.toDto(content)).thenReturn(expectedResponse); + + // when + ContentResponse result = contentService.update(contentId, request, null); + + // then + assertThat(result).isSameAs(expectedResponse); + assertThat(content.getTitle()).isEqualTo("수정 제목"); + assertThat(content.getDescription()).isEqualTo("수정 설명"); + verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); + } + + @Test + @DisplayName("기존 썸네일이 없을 때 새 썸네일로 수정에 성공한다") + void update_withNewThumbnail_noOldThumbnail_success() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "new.jpg"); + ContentResponse expectedResponse = new ContentResponse( + contentId, ContentType.MOVIE, "수정 제목", null, + "http://localhost/thumbnails/new.jpg", BinaryContentUploadStatus.PENDING, + List.of("액션"), 0.0, 0, 0 + ); + + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); + when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("new.jpg"))) + .thenReturn(new GeneratedKey("thumbnails/new.jpg", "image/jpeg")); + when(binaryContentStorage.toUrl("thumbnails/new.jpg")) + .thenReturn("http://localhost/thumbnails/new.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(contentMapper.toDto(content)).thenReturn(expectedResponse); + + // when + ContentResponse result = contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), image); + + // then + assertThat(result).isSameAs(expectedResponse); + assertThat(content.getThumbnail()).isNotNull(); + verify(binaryContentRepository).save(any(BinaryContent.class)); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BinaryContentUploadEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/new.jpg"); + } + + @Test + @DisplayName("기존 썸네일이 있을 때 새 썸네일로 교체하면 기존 썸네일이 DELETED 상태가 된다") + void update_withNewThumbnail_oldThumbnailMarkedDeleted() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); + BinaryContent oldThumbnail = BinaryContent.pending("http://localhost/thumbnails/old.jpg"); + content.attachThumbnail(oldThumbnail); + FileRequest image = new FileRequest(new byte[]{4, 5, 6}, "new.jpg"); + + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); + when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("new.jpg"))) + .thenReturn(new GeneratedKey("thumbnails/new.jpg", "image/jpeg")); + when(binaryContentStorage.toUrl("thumbnails/new.jpg")) + .thenReturn("http://localhost/thumbnails/new.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(contentMapper.toDto(content)).thenReturn(null); + + // when + contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), image); + + // then + assertThat(oldThumbnail.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED); + assertThat(content.getThumbnail()).isNotSameAs(oldThumbnail); + } + + @Test + @DisplayName("수정 시 유지되는 태그는 delete/insert 없이 그대로 유지된다") + void update_retainedTags_notReinserted() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); + ReflectionTestUtils.setField(content, "id", contentId); + + Tag actionTag = tagWithId("액션"); + Tag sfTag = tagWithId("sf"); + content.addTag(ContentTag.create(content, actionTag)); // 유지될 태그 + content.addTag(ContentTag.create(content, sfTag)); // 제거될 태그 + + // 요청: 액션 유지 + 코미디 추가 (sf 제거) + ContentUpdateRequest request = new ContentUpdateRequest("기존 제목", null, List.of("액션", "코미디")); + Tag comedyTag = tagWithId("코미디"); + + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + when(tagRepository.findByNameIn(List.of("코미디"))).thenReturn(List.of(comedyTag)); + when(contentMapper.toDto(content)).thenReturn(null); + + // when + contentService.update(contentId, request, null); + + // then: 추가할 태그만 조회 + verify(tagRepository).findByNameIn(List.of("코미디")); + verify(tagRepository, never()).saveAll(anyList()); + + Set finalTagNames = content.getContentTags().stream() + .map(ct -> ct.getTag().getName()) + .collect(Collectors.toSet()); + assertThat(finalTagNames).containsExactlyInAnyOrder("액션", "코미디"); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 수정 시 예외를 던진다") + void update_notFound_throwsException() { + // given + UUID contentId = UUID.randomUUID(); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> contentService.update( + contentId, new ContentUpdateRequest("제목", null, List.of("액션")), null)) + .isInstanceOf(ContentNotFoundException.class); + + verifyNoInteractions(tagRepository, binaryContentRepository, eventPublisher, contentMapper); + } + + @Test + @DisplayName("수정 시 정규화 후 유효한 태그가 없으면 예외를 던지고 엔티티는 변경되지 않는다") + void update_allBlankTags_throwsException() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + + // when & then + assertThatThrownBy(() -> contentService.update( + contentId, new ContentUpdateRequest("수정 제목", null, List.of(" ", " ")), null)) + .isInstanceOf(EmptyTagException.class); + + assertThat(content.getTitle()).isEqualTo("기존 제목"); + verify(tagRepository, never()).findByNameIn(anyList()); + verifyNoInteractions(binaryContentRepository, eventPublisher, contentMapper); + } + + // --- DELETE --- + @Test + @DisplayName("썸네일 없는 콘텐츠 삭제에 성공한다") + void delete_withoutThumbnail_success() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "테스트 영화", null); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + + // when + contentService.delete(contentId); + + // then + verify(contentRepository).delete(content); + verifyNoInteractions(binaryContentRepository, eventPublisher); + } + + @Test + @DisplayName("썸네일 있는 콘텐츠 삭제 시 썸네일이 DELETED 상태가 된다") + void delete_withThumbnail_marksDeletedAndDeletes() { + // given + UUID contentId = UUID.randomUUID(); + Content content = Content.createByAdmin(ContentType.MOVIE, "테스트 영화", null); + BinaryContent thumbnail = BinaryContent.pending("http://localhost/thumbnails/thumb.jpg"); + content.attachThumbnail(thumbnail); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); + + // when + contentService.delete(contentId); + + // then + assertThat(thumbnail.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED); + verify(contentRepository).delete(content); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠 삭제 시 예외를 던진다") + void delete_notFound_throwsException() { + // given + UUID contentId = UUID.randomUUID(); + when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> contentService.delete(contentId)) + .isInstanceOf(ContentNotFoundException.class); + + verify(contentRepository, never()).delete(any()); + } + + private Tag tagWithId(String name) { + Tag tag = Tag.create(name); + ReflectionTestUtils.setField(tag, "id", UUID.randomUUID()); + return tag; + } } diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java index 7064115f..1b1c2f07 100644 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java @@ -183,7 +183,7 @@ void update_nameOnly_success() { userId, Instant.parse("2026-06-25T00:00:00Z"), "user@example.com", "새이름", null, "USER", false ); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.of(user)); when(userMapper.toDto(user)).thenReturn(expected); // When @@ -207,7 +207,7 @@ void update_withImage_success() { userId, Instant.parse("2026-06-25T00:00:00Z"), "user@example.com", "새이름", "http://localhost/profiles/key.jpg", "USER", false ); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.of(user)); when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq(user.getId()), eq("profile.jpg"))) .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); when(binaryContentStorage.toUrl("profiles/key.jpg")) @@ -231,13 +231,13 @@ void update_withImage_success() { void update_notFound_throwsException() { // Given UUID userId = UUID.randomUUID(); - when(userRepository.findById(userId)).thenReturn(Optional.empty()); + when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.empty()); // When & Then assertThatThrownBy(() -> userService.update(userId, new UserUpdateRequest("새이름"), null)) .isInstanceOf(UserNotFoundException.class); - verify(userRepository).findById(userId); + verify(userRepository).findWithProfileImageById(userId); verifyNoInteractions(userMapper, binaryContentRepository, eventPublisher); } }