From 7b1fc65259577ae4e782513583a979a6955a848c Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 15:10:31 +0900 Subject: [PATCH 01/25] =?UTF-8?q?refactor:=20EntityGraph=20attributePaths?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/repository/ContentRepository.java | 2 +- .../mopl/watcher/repository/WatchingSessionRepository.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/watcher/repository/WatchingSessionRepository.java b/src/main/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepository.java index e1497904..6522035a 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", "content.contentTags", "content.contentTags.tag"}) Window findByContentId(UUID contentId, ScrollPosition position, Limit limit, Sort sort); From 920ee396f2f8ca94a6bf99a37e7268074fe2a753 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 15:29:22 +0900 Subject: [PATCH 02/25] =?UTF-8?q?feat:=20ContentController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 16 ++++++++++++++ .../content/controller/api/ContentApi.java | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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..7e3290d9 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,16 +2,21 @@ 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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -35,4 +40,15 @@ public ResponseEntity postContent( ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + @Override + @PatchMapping(value = "/{contentId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity patchContent( + @PathVariable UUID contentId, + @Valid @RequestBody ContentUpdateRequest request + ) { + log.info("Content Update request: PATCH /api/contents/{}", contentId); + ContentResponse response = contentService.update(contentId, request); + return ResponseEntity.ok(response); + } } 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..fdc7bb6f 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,6 +1,7 @@ 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 io.swagger.v3.oas.annotations.Operation; @@ -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; @@ -50,4 +52,24 @@ ResponseEntity postContent( @Parameter(hidden = true) ContentCreateRequest request, @Parameter(hidden = true) 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 = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "콘텐츠 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity patchContent( + @Parameter(description = "콘텐츠 ID") UUID contentId, + @Parameter(hidden = true) ContentUpdateRequest request + ); } \ No newline at end of file From 172cd1635630bc3c9abfa3257ad0893239bc1fa6 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 15:30:17 +0900 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20Content=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B9=84=EC=A7=80=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ContentUpdateRequest.java | 20 +++++++++++++++++++ .../team5/mopl/content/entity/Content.java | 17 ++++++++++++++++ .../mopl/content/service/ContentService.java | 15 ++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/content/dto/request/ContentUpdateRequest.java 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..b9b4d307 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ContentUpdateRequest.java @@ -0,0 +1,20 @@ +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 = "콘텐츠 태그 목록은 비어있을 수 없습니다.") + 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..882e9fe8 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 @@ -1,7 +1,9 @@ package com.codeit.team5.mopl.content.entity; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.content.exception.InvalidContentDescriptionException; 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; @@ -116,4 +118,19 @@ public void addTag(ContentTag contentTag) { } contentTags.add(contentTag); } + + public void update(String title, String description) { + if (title == null || title.isBlank()) { + throw new InvalidContentTitleException(); + } + if (description == null || description.isBlank()) { + throw new InvalidContentDescriptionException(); + } + this.title = title; + this.description = description; + } + + public void clearTags() { + contentTags.clear(); + } } 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..d2c37de0 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 @@ -8,9 +8,11 @@ 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.mapper.ContentMapper; import com.codeit.team5.mopl.content.repository.ContentRepository; @@ -21,6 +23,7 @@ import com.codeit.team5.mopl.tag.repository.TagRepository; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -69,6 +72,18 @@ public ContentResponse create(ContentCreateRequest request, FileRequest thumbnai return contentMapper.toDto(content); } + @Transactional + public ContentResponse update(UUID contentId, ContentUpdateRequest request) { + Content content = contentRepository.findWithStatsAndTagsById(contentId) + .orElseThrow(ContentNotFoundException::new); + + content.update(request.title(), request.description()); + content.clearTags(); + attachTags(content, request.tags()); + + return contentMapper.toDto(content); + } + private void attachTags(Content content, List rawTagNames) { List tagNames = rawTagNames.stream() .map(String::trim) From f8bafbcf307d016d6019f3231258eb6d3f3fbf8d Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 15:31:10 +0900 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20Content=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/InvalidContentDescriptionException.java | 10 ++++++++++ .../exception/InvalidContentTitleException.java | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java create mode 100644 src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTitleException.java diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java new file mode 100644 index 00000000..277360f6 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.exception; + +import org.springframework.http.HttpStatus; + +public class InvalidContentDescriptionException extends ContentException { + + public InvalidContentDescriptionException() { + super(HttpStatus.BAD_REQUEST, "설명은 필수입니다."); + } +} 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, "제목은 필수입니다."); + } +} From b5da4f30c05bdccd95c6139ae75037066d922b93 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 15:50:27 +0900 Subject: [PATCH 05/25] =?UTF-8?q?refactor:=20Content=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?=EC=9D=80=20nullable=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/content/entity/Content.java | 4 ---- .../exception/InvalidContentDescriptionException.java | 10 ---------- 2 files changed, 14 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java 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 882e9fe8..83c29f63 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 @@ -1,7 +1,6 @@ package com.codeit.team5.mopl.content.entity; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.content.exception.InvalidContentDescriptionException; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.content.exception.InvalidContentTitleException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; @@ -123,9 +122,6 @@ public void update(String title, String description) { if (title == null || title.isBlank()) { throw new InvalidContentTitleException(); } - if (description == null || description.isBlank()) { - throw new InvalidContentDescriptionException(); - } this.title = title; this.description = description; } diff --git a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java b/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java deleted file mode 100644 index 277360f6..00000000 --- a/src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentDescriptionException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.codeit.team5.mopl.content.exception; - -import org.springframework.http.HttpStatus; - -public class InvalidContentDescriptionException extends ContentException { - - public InvalidContentDescriptionException() { - super(HttpStatus.BAD_REQUEST, "설명은 필수입니다."); - } -} From 47596ceb287a0d20d93459aad810537c4e481b44 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 16:15:40 +0900 Subject: [PATCH 06/25] =?UTF-8?q?refactor:=20Content=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 8 +++---- .../content/controller/api/ContentApi.java | 21 ++++++++++++++++--- .../mopl/content/service/ContentService.java | 14 ++++++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) 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 7e3290d9..9246e68c 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 @@ -16,7 +16,6 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -42,13 +41,14 @@ public ResponseEntity postContent( } @Override - @PatchMapping(value = "/{contentId}", consumes = MediaType.APPLICATION_JSON_VALUE) + @PatchMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity patchContent( @PathVariable UUID contentId, - @Valid @RequestBody ContentUpdateRequest request + @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); + ContentResponse response = contentService.update(contentId, request, thumbnail); return ResponseEntity.ok(response); } } 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 fdc7bb6f..0467516d 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 @@ -22,7 +22,7 @@ public interface ContentApi { @Schema(name = "ContentCreateMultipartRequest") - class MultipartRequest { + class ContentCreateMultipartRequest { @Schema(implementation = ContentCreateRequest.class) public ContentCreateRequest request; @@ -45,7 +45,7 @@ class MultipartRequest { }) @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( @@ -53,6 +53,15 @@ ResponseEntity postContent( @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 = "성공", @@ -68,8 +77,14 @@ ResponseEntity postContent( @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.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) ContentUpdateRequest request, + @Parameter(hidden = true) MultipartFile thumbnail ); } \ No newline at end of file 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 d2c37de0..1bcdaca1 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 @@ -73,7 +73,7 @@ public ContentResponse create(ContentCreateRequest request, FileRequest thumbnai } @Transactional - public ContentResponse update(UUID contentId, ContentUpdateRequest request) { + public ContentResponse update(UUID contentId, ContentUpdateRequest request, MultipartFile thumbnail) { Content content = contentRepository.findWithStatsAndTagsById(contentId) .orElseThrow(ContentNotFoundException::new); @@ -81,6 +81,18 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request) { content.clearTags(); attachTags(content, request.tags()); + if (thumbnail != null && !thumbnail.isEmpty()) { + try { + String key = binaryContentStorage.generateKey(contentId, thumbnail.getOriginalFilename()); + BinaryContent binaryContent = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(key))); + content.attachThumbnail(binaryContent); + eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.getBytes())); + } catch (IOException e) { + log.warn("썸네일 바이트 읽기 실패 - contentId: {}", contentId, e); + } + } + return contentMapper.toDto(content); } From 18527bba3f021993458eab980a3f3c46e0f5cf1f Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 16:35:14 +0900 Subject: [PATCH 07/25] =?UTF-8?q?refactor:=20=EC=8D=B8=EB=84=A4=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=8B=9C=EC=97=90=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?PENDING=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/content/service/ContentService.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 1bcdaca1..117e4dbe 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,6 +5,7 @@ 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; @@ -83,6 +84,11 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, Mult if (thumbnail != null && !thumbnail.isEmpty()) { try { + BinaryContent oldThumbnail = content.getThumbnail(); + if (oldThumbnail != null) { + // 기존 썸네일 상태를 PENDING으로 변경 > 추후 스케줄러로 모아서 정리 + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.PENDING); + } String key = binaryContentStorage.generateKey(contentId, thumbnail.getOriginalFilename()); BinaryContent binaryContent = binaryContentRepository.save( BinaryContent.pending(binaryContentStorage.toUrl(key))); From d4b9ca2f8b23dae8aa4f584f2dfabe0b87f2c668 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 17:03:34 +0900 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20ContentController=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 9 +++++++++ .../mopl/content/controller/api/ContentApi.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) 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 9246e68c..f0651cc1 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 @@ -13,6 +13,7 @@ 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; @@ -51,4 +52,12 @@ public ResponseEntity patchContent( ContentResponse response = contentService.update(contentId, request, 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 0467516d..f430bbda 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 @@ -87,4 +87,20 @@ ResponseEntity patchContent( @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 = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "콘텐츠 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity deleteContent(@Parameter(description = "콘텐츠 ID") UUID contentId); } \ No newline at end of file From 76f37fb5cb673e0c6c6701bacee438b34e6e36e0 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 17:03:50 +0900 Subject: [PATCH 09/25] =?UTF-8?q?feat:=20Content=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B9=84=EC=A7=80=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/service/ContentService.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 117e4dbe..cdfdffc8 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 @@ -102,6 +102,17 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, Mult return contentMapper.toDto(content); } + @Transactional + public void delete(UUID contentId) { + Content content = contentRepository.findWithStatsAndTagsById(contentId) + .orElseThrow(ContentNotFoundException::new); + BinaryContent oldThumbnail = content.getThumbnail(); + if (oldThumbnail != null) { + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.PENDING); + } + contentRepository.delete(content); + } + private void attachTags(Content content, List rawTagNames) { List tagNames = rawTagNames.stream() .map(String::trim) From 9a940c39c887649f0f3cc9691ce5b3f394f45514 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 17:40:21 +0900 Subject: [PATCH 10/25] =?UTF-8?q?refactor:=20BinaryContentUploadStatus?= =?UTF-8?q?=EC=97=90=20DELETED=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20=EA=B8=B0=EC=A1=B4=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=8B=9C=20=EC=83=81=ED=83=9C=20DELETED?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/entity/BinaryContentUploadStatus.java | 3 ++- .../com/codeit/team5/mopl/content/entity/Content.java | 8 ++++---- .../codeit/team5/mopl/content/service/ContentService.java | 5 ++--- .../V10__add_deleted_status_to_binary_contents.sql | 6 ++++++ 4 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/migration/V10__add_deleted_status_to_binary_contents.sql 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/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index 83c29f63..eb65eb9e 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 @@ -118,6 +118,10 @@ public void addTag(ContentTag contentTag) { contentTags.add(contentTag); } + public void clearTags() { + contentTags.clear(); + } + public void update(String title, String description) { if (title == null || title.isBlank()) { throw new InvalidContentTitleException(); @@ -125,8 +129,4 @@ public void update(String title, String description) { this.title = title; this.description = description; } - - public void clearTags() { - contentTags.clear(); - } } 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 cdfdffc8..f8180f39 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 @@ -86,8 +86,7 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, Mult try { BinaryContent oldThumbnail = content.getThumbnail(); if (oldThumbnail != null) { - // 기존 썸네일 상태를 PENDING으로 변경 > 추후 스케줄러로 모아서 정리 - oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.PENDING); + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); } String key = binaryContentStorage.generateKey(contentId, thumbnail.getOriginalFilename()); BinaryContent binaryContent = binaryContentRepository.save( @@ -108,7 +107,7 @@ public void delete(UUID contentId) { .orElseThrow(ContentNotFoundException::new); BinaryContent oldThumbnail = content.getThumbnail(); if (oldThumbnail != null) { - oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.PENDING); + oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); } contentRepository.delete(content); } diff --git a/src/main/resources/db/migration/V10__add_deleted_status_to_binary_contents.sql b/src/main/resources/db/migration/V10__add_deleted_status_to_binary_contents.sql new file mode 100644 index 00000000..3abad823 --- /dev/null +++ b/src/main/resources/db/migration/V10__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')); From af83fd34d8f0e62e1c7dbe28c4d3948e1d9d9885 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 17:46:25 +0900 Subject: [PATCH 11/25] =?UTF-8?q?refactor:=20=EC=B5=9C=EB=8C=80=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EB=B9=84=EC=A7=80=EB=8B=88=EC=8A=A4=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/dto/request/ContentCreateRequest.java | 1 + .../mopl/content/dto/request/ContentUpdateRequest.java | 1 + .../mopl/content/exception/TooManyTagsException.java | 10 ++++++++++ .../team5/mopl/content/service/ContentService.java | 4 ++++ 4 files changed, 16 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/content/exception/TooManyTagsException.java 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 index b9b4d307..1ff5e337 100644 --- 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 @@ -15,6 +15,7 @@ public record ContentUpdateRequest( @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/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/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index f8180f39..8cb79946 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 @@ -15,6 +15,7 @@ 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; @@ -123,6 +124,9 @@ private void attachTags(Content content, List rawTagNames) { if (tagNames.isEmpty()) { throw new EmptyTagException(); } + if (tagNames.size() > 10) { + throw new TooManyTagsException(); + } Map existingTags = tagRepository.findByNameIn(tagNames).stream() .collect(Collectors.toMap(Tag::getName, Function.identity())); From 0c998247da3de186afc333d6610c8f23dd38a0cb Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 18:47:00 +0900 Subject: [PATCH 12/25] =?UTF-8?q?refactor:=20ContentApi=20ErrorResponse=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/api/ContentApi.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 f430bbda..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 @@ -3,7 +3,7 @@ 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; @@ -35,13 +35,13 @@ class ContentCreateMultipartRequest { @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, @@ -67,15 +67,15 @@ class ContentUpdateMultipartRequest { @ApiResponse(responseCode = "200", 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 = "404", 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, @@ -92,15 +92,15 @@ ResponseEntity patchContent( @ApiResponses({ @ApiResponse(responseCode = "204", description = "성공"), @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 = "404", 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))) }) ResponseEntity deleteContent(@Parameter(description = "콘텐츠 ID") UUID contentId); } \ No newline at end of file From 2e5fc957d67e65e34ad5cb7dd104fb68bc8e1cb9 Mon Sep 17 00:00:00 2001 From: plzslp Date: Thu, 25 Jun 2026 18:49:11 +0900 Subject: [PATCH 13/25] =?UTF-8?q?test:=20ContentRepository=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ContentRepositoryTest.java | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java 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); + } +} From 3d433796bfd4c13cbcb71258ef79a1d7b572c981 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:02:01 +0900 Subject: [PATCH 14/25] =?UTF-8?q?refactor:=20ContentNotFoundException=20de?= =?UTF-8?q?tails=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/exception/ContentNotFoundException.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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)); } } From daba827837119d2b7f61bf39bf4c01378d875774 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:05:08 +0900 Subject: [PATCH 15/25] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20ContentController/Service=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/controller/ContentController.java | 2 +- .../mopl/content/service/ContentService.java | 48 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) 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 f0651cc1..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 @@ -49,7 +49,7 @@ public ResponseEntity patchContent( @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail ) { log.info("Content Update request: PATCH /api/contents/{}", contentId); - ContentResponse response = contentService.update(contentId, request, thumbnail); + ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.ok(response); } 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 8cb79946..e7cf5aa7 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 @@ -50,20 +50,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()); @@ -75,28 +70,21 @@ public ContentResponse create(ContentCreateRequest request, FileRequest thumbnai } @Transactional - public ContentResponse update(UUID contentId, ContentUpdateRequest request, MultipartFile thumbnail) { + public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) { Content content = contentRepository.findWithStatsAndTagsById(contentId) - .orElseThrow(ContentNotFoundException::new); + .orElseThrow(() -> new ContentNotFoundException(contentId)); content.update(request.title(), request.description()); content.clearTags(); attachTags(content, request.tags()); - if (thumbnail != null && !thumbnail.isEmpty()) { - try { - BinaryContent oldThumbnail = content.getThumbnail(); - if (oldThumbnail != null) { - oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); - } - String key = binaryContentStorage.generateKey(contentId, thumbnail.getOriginalFilename()); - BinaryContent binaryContent = binaryContentRepository.save( - BinaryContent.pending(binaryContentStorage.toUrl(key))); - content.attachThumbnail(binaryContent); - eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.getBytes())); - } catch (IOException e) { - log.warn("썸네일 바이트 읽기 실패 - contentId: {}", contentId, e); + 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); @@ -105,7 +93,7 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, Mult @Transactional public void delete(UUID contentId) { Content content = contentRepository.findWithStatsAndTagsById(contentId) - .orElseThrow(ContentNotFoundException::new); + .orElseThrow(() -> new ContentNotFoundException(contentId)); BinaryContent oldThumbnail = content.getThumbnail(); if (oldThumbnail != null) { oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); @@ -113,6 +101,18 @@ public void delete(UUID contentId) { contentRepository.delete(content); } + private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) { + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename()); + BinaryContent profileImage = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); + + eventPublisher.publishEvent( + new BinaryContentUploadEvent(profileImage.getId(), generated.key(), image.bytes(), generated.contentType())); + // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 썸네일 이미지 롤백 + + return profileImage; + } + private void attachTags(Content content, List rawTagNames) { List tagNames = rawTagNames.stream() .map(String::trim) From 1dc7b6699cdf6bc63bfd7b80f86ca93756acf9c2 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:06:02 +0900 Subject: [PATCH 16/25] =?UTF-8?q?refactor:=20User=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=ED=94=84=EB=A1=9C=ED=95=84=20Soft=20Delet?= =?UTF-8?q?e=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20UserRepositor?= =?UTF-8?q?y:=20N+1=20=EB=AC=B8=EC=A0=9C=20=EB=B0=A9=EC=A7=80=20update?= =?UTF-8?q?=EC=9A=A9=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/user/repository/UserRepository.java | 5 +++++ .../codeit/team5/mopl/user/service/UserService.java | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) 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()))); From 18f55bbb5b7f8fba38ff9b365cc2f5c543998d02 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:20:27 +0900 Subject: [PATCH 17/25] =?UTF-8?q?refactor:=20V10=20schema.sql=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20V11=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ontents.sql => V11__add_deleted_status_to_binary_contents.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V10__add_deleted_status_to_binary_contents.sql => V11__add_deleted_status_to_binary_contents.sql} (100%) diff --git a/src/main/resources/db/migration/V10__add_deleted_status_to_binary_contents.sql b/src/main/resources/db/migration/V11__add_deleted_status_to_binary_contents.sql similarity index 100% rename from src/main/resources/db/migration/V10__add_deleted_status_to_binary_contents.sql rename to src/main/resources/db/migration/V11__add_deleted_status_to_binary_contents.sql From b44bd1a1b10a7a22a05970d16f2bb401f94c4eb7 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:21:45 +0900 Subject: [PATCH 18/25] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?findWithProfileImageById()=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/user/service/UserServiceTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); } } From 2fafd8c69d6c28ef0f82284bcaf9ca46eb9f19de Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 11:22:09 +0900 Subject: [PATCH 19/25] =?UTF-8?q?refactor:=20ContentException=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=81=B4=EB=9E=98=EC=8A=A4=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/content/exception/ContentException.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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); + } } From baaee05e9781641c629ccc2606982e126ffe6ac8 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 13:01:50 +0900 Subject: [PATCH 20/25] =?UTF-8?q?test:=20ContentController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ContentControllerTest.java | 295 +++++++++++++++++- 1 file changed, 293 insertions(+), 2 deletions(-) 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("서버 내부 에러가 발생했습니다.")); + } } From ae254d32cc0a23b0671536dc6c21db96facfa810 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 13:02:49 +0900 Subject: [PATCH 21/25] =?UTF-8?q?test:=20ContentService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/ContentServiceTest.java | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) 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..8e42dbdd 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,14 @@ 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.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,6 +33,7 @@ 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.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -71,6 +75,7 @@ class ContentServiceTest { @InjectMocks private ContentService contentService; + // --- CREATE --- @Test @DisplayName("콘텐츠 생성에 성공한다") void create_success() { @@ -242,4 +247,173 @@ 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("존재하지 않는 콘텐츠 수정 시 예외를 던진다") + 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); + + 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()); + } } From 3ff1776f496a3e366ffa26bd2bfc3614c92fd331 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 14:26:33 +0900 Subject: [PATCH 22/25] =?UTF-8?q?refactor:=20ContentService=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20diff?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/service/ContentService.java | 54 +++++++++++++------ .../content/service/ContentServiceTest.java | 44 +++++++++++++++ 2 files changed, 83 insertions(+), 15 deletions(-) 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 e7cf5aa7..836d64b5 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 @@ -23,8 +23,10 @@ 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; @@ -75,8 +77,7 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File .orElseThrow(() -> new ContentNotFoundException(contentId)); content.update(request.title(), request.description()); - content.clearTags(); - attachTags(content, request.tags()); + updateTags(content, request.tags()); if (image != null) { // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) @@ -103,31 +104,41 @@ public void delete(UUID contentId) { private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) { GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename()); - BinaryContent profileImage = binaryContentRepository.save( + BinaryContent thumbnailImage = binaryContentRepository.save( BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); eventPublisher.publishEvent( - new BinaryContentUploadEvent(profileImage.getId(), generated.key(), image.bytes(), generated.contentType())); + new BinaryContentUploadEvent(thumbnailImage.getId(), generated.key(), image.bytes(), generated.contentType())); // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 썸네일 이미지 롤백 - return profileImage; + 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 rawTagNames) { + List requestedNames = normalizeTagNames(rawTagNames); + + 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 (tagNames.size() > 10) { - throw new TooManyTagsException(); + 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())); @@ -142,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/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index 8e42dbdd..2fd01632 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 @@ -24,6 +24,7 @@ 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; @@ -34,7 +35,10 @@ 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; @@ -336,6 +340,40 @@ void update_withNewThumbnail_oldThumbnailMarkedDeleted() { 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() { @@ -416,4 +454,10 @@ void delete_notFound_throwsException() { verify(contentRepository, never()).delete(any()); } + + private Tag tagWithId(String name) { + Tag tag = Tag.create(name); + ReflectionTestUtils.setField(tag, "id", UUID.randomUUID()); + return tag; + } } From c67621f03f4a915cd2d4340df186a9c5a4edde02 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 14:27:40 +0900 Subject: [PATCH 23/25] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20toMany=20Ent?= =?UTF-8?q?ityGraph=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/watcher/repository/WatchingSessionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6522035a..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 @@ -21,7 +21,7 @@ public interface WatchingSessionRepository extends JpaRepository findByContentId(UUID contentId, ScrollPosition position, Limit limit, Sort sort); From 466307d920c5871045fff776a7c51298c435f6db Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 14:37:22 +0900 Subject: [PATCH 24/25] =?UTF-8?q?refactor:=20=EC=BD=98=ED=85=90=EC=B8=A0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=A4=91=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B2=80=EC=A6=9D,=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=ED=98=B8=EC=B6=9C=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/content/service/ContentService.java | 8 ++++---- .../team5/mopl/content/service/ContentServiceTest.java | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) 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 836d64b5..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 @@ -76,8 +76,10 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File Content content = contentRepository.findWithStatsAndTagsById(contentId) .orElseThrow(() -> new ContentNotFoundException(contentId)); + List tagNames = normalizeTagNames(request.tags()); + content.update(request.title(), request.description()); - updateTags(content, request.tags()); + updateTags(content, tagNames); if (image != null) { // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) @@ -119,9 +121,7 @@ private void attachTags(Content content, List rawTagNames) { insertTags(content, tagNames); } - private void updateTags(Content content, List rawTagNames) { - List requestedNames = normalizeTagNames(rawTagNames); - + private void updateTags(Content content, List requestedNames) { Set currentNames = content.getContentTags().stream() .map(ct -> ct.getTag().getName()) .collect(Collectors.toSet()); 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 2fd01632..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 @@ -390,7 +390,7 @@ contentId, new ContentUpdateRequest("제목", null, List.of("액션")), null)) } @Test - @DisplayName("수정 시 정규화 후 유효한 태그가 없으면 예외를 던진다") + @DisplayName("수정 시 정규화 후 유효한 태그가 없으면 예외를 던지고 엔티티는 변경되지 않는다") void update_allBlankTags_throwsException() { // given UUID contentId = UUID.randomUUID(); @@ -402,6 +402,7 @@ void update_allBlankTags_throwsException() { contentId, new ContentUpdateRequest("수정 제목", null, List.of(" ", " ")), null)) .isInstanceOf(EmptyTagException.class); + assertThat(content.getTitle()).isEqualTo("기존 제목"); verify(tagRepository, never()).findByNameIn(anyList()); verifyNoInteractions(binaryContentRepository, eventPublisher, contentMapper); } From 3dc5fafbb7454396538b365d0f62ec3c015fdbc6 Mon Sep 17 00:00:00 2001 From: plzslp Date: Fri, 26 Jun 2026 15:17:07 +0900 Subject: [PATCH 25/25] =?UTF-8?q?refactor:=20=EC=BD=98=ED=85=90=EC=B8=A0?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20update()=20StringUtils=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/content/entity/Content.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 eb65eb9e..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 @@ -24,6 +24,7 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import org.springframework.util.StringUtils; @Getter @Entity @@ -118,12 +119,8 @@ public void addTag(ContentTag contentTag) { contentTags.add(contentTag); } - public void clearTags() { - contentTags.clear(); - } - public void update(String title, String description) { - if (title == null || title.isBlank()) { + if (!StringUtils.hasText(title)) { throw new InvalidContentTitleException(); } this.title = title;