diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 35c69900d..378c98a14 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -130,7 +130,8 @@ public enum ErrorCode implements ResponseCode { FEED_NOT_FOUND(HttpStatus.NOT_FOUND, 160000, "존재하지 않는 FEED 입니다."), TAG_NAME_NOT_MATCH(HttpStatus.BAD_REQUEST, 160001, "일치하는 태그 이름이 없습니다."), TAG_NOT_FOUND(HttpStatus.NOT_FOUND, 160002, "존재하지 않는 TAG 입니다."), - INVALID_FEED_CREATE(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성 요청 입니다."), + INVALID_FEED_COMMAND(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성/수정 요청 입니다."), + FEED_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, 160004, "피드 수정 권한이 없습니다."), /** * 170000 : Image File error diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java index 9e6c8bb6e..713a0e429 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java @@ -4,13 +4,13 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.feed.adapter.in.web.request.FeedCreateRequest; -import konkuk.thip.feed.adapter.in.web.response.FeedCreateResponse; +import konkuk.thip.feed.adapter.in.web.request.FeedUpdateRequest; +import konkuk.thip.feed.adapter.in.web.response.FeedIdResponse; import konkuk.thip.feed.application.port.in.FeedCreateUseCase; +import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -21,11 +21,23 @@ public class FeedCommandController { private final FeedCreateUseCase feedCreateUseCase; + private final FeedUpdateUseCase feedUpdateUseCase; + //피드 작성 @PostMapping("/feeds") - public BaseResponse createFeed(@RequestPart("request") @Valid final FeedCreateRequest request, - @RequestPart(value = "images", required = false) final List images, - @UserId final Long userId) { - return BaseResponse.ok(FeedCreateResponse.of(feedCreateUseCase.createFeed(request.toCommand(userId),images))); + public BaseResponse createFeed(@RequestPart("request") @Valid final FeedCreateRequest request, + @RequestPart(value = "images", required = false) final List images, + @UserId final Long userId) { + return BaseResponse.ok(FeedIdResponse.of(feedCreateUseCase.createFeed(request.toCommand(userId),images))); + } + + // 피드 수정 (책 빼고 변경가능) + @PatchMapping("/feeds/{feedId}") + public BaseResponse updateFeed(@RequestBody @Valid final FeedUpdateRequest request, + @PathVariable("feedId") final Long feedId, + @UserId final Long userId) { + + return BaseResponse.ok(FeedIdResponse.of(feedUpdateUseCase.updateFeed(request.toCommand(userId,feedId)))); + } } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java index f695ea8bf..a4a518638 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java @@ -17,8 +17,6 @@ public record FeedCreateRequest( @NotNull(message = "방 공개 설정 여부는 필수입니다.") Boolean isPublic, - String category, - List tagList ) { public FeedCreateCommand toCommand(Long userId) { @@ -26,7 +24,6 @@ public FeedCreateCommand toCommand(Long userId) { isbn, contentBody, isPublic, - category, tagList, userId ); diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java new file mode 100644 index 000000000..d18433731 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java @@ -0,0 +1,27 @@ +package konkuk.thip.feed.adapter.in.web.request; + +import konkuk.thip.feed.application.port.in.dto.FeedUpdateCommand; + +import java.util.List; + +public record FeedUpdateRequest( + + String contentBody, + + Boolean isPublic, + + List tagList, + + List remainImageUrls +) { + public FeedUpdateCommand toCommand(Long userId, Long feedId) { + return new FeedUpdateCommand( + contentBody, + isPublic, + tagList, + remainImageUrls, + userId, + feedId + ); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java deleted file mode 100644 index 5c8dce218..000000000 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.feed.adapter.in.web.response; - -public record FeedCreateResponse(Long feedId) { - public static FeedCreateResponse of(Long feedId) { - return new FeedCreateResponse(feedId); - } -} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java new file mode 100644 index 000000000..22e68ccba --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.adapter.in.web.response; + +public record FeedIdResponse(Long feedId) { + public static FeedIdResponse of(Long feedId) { + return new FeedIdResponse(feedId); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java index 26e140844..cd184408e 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.feed.domain.Feed; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import lombok.AccessLevel; @@ -40,4 +41,12 @@ public FeedJpaEntity(String content, Integer likeCount, Integer commentCount, Us this.bookJpaEntity = bookJpaEntity; this.contentList = contentList; } + + public void updateFrom(Feed feed) { + this.content = feed.getContent(); + this.isPublic = feed.getIsPublic(); + this.reportCount = feed.getReportCount(); + this.likeCount = feed.getLikeCount(); + this.commentCount = feed.getCommentCount(); + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java b/src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java index dfb48382f..15581ae27 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java @@ -6,14 +6,18 @@ import konkuk.thip.feed.domain.Feed; import konkuk.thip.feed.domain.Tag; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; @Component +@RequiredArgsConstructor public class FeedMapper { + private final ContentMapper contentMapper; + public FeedJpaEntity toJpaEntity(Feed feed, UserJpaEntity userJpaEntity, BookJpaEntity bookJpaEntity) { return FeedJpaEntity.builder() .content(feed.getContent()) @@ -41,6 +45,9 @@ public Feed toDomainEntity(FeedJpaEntity feedJpaEntity, List tagJp .map(TagJpaEntity::getValue) .map(Tag::from) .toList()) + .contentList(feedJpaEntity.getContentList().stream() + .map(contentMapper::toDomainEntity) + .toList()) .createdAt(feedJpaEntity.getCreatedAt()) .modifiedAt(feedJpaEntity.getModifiedAt()) .status(feedJpaEntity.getStatus()) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index 2bc104b3d..3ff52382b 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -18,14 +18,12 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.List; import static konkuk.thip.common.exception.code.ErrorCode.*; -@Slf4j @Repository @RequiredArgsConstructor public class FeedCommandPersistenceAdapter implements FeedCommandPort { @@ -38,6 +36,16 @@ public class FeedCommandPersistenceAdapter implements FeedCommandPort { private final FeedMapper feedMapper; private final ContentMapper contentMapper; + @Override + public Feed findById(Long id) { + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + + List tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId()); + + return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList); + } + @Override public Long save(Feed feed) { @@ -53,26 +61,38 @@ public Long save(Feed feed) { FeedJpaEntity savedFeed = feedJpaRepository.save(feedJpaEntity); // Content가 존재하면 ContentJpaEntity 생성 및 Feed 연관관계 설정 - saveContents(feed, savedFeed); + applyFeedContents(feed, savedFeed); // 태그가 존재하면 태그 피드 매핑 생성 및 저장 - saveFeedTags(feed, savedFeed); + applyFeedTags(feed, savedFeed); return savedFeed.getPostId(); } - private void saveContents(Feed feed, FeedJpaEntity feedJpaEntity) { - if (feed.getContentList().isEmpty()) return; + @Override + public Long update(Feed feed) { + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(feed.getId()) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + feedJpaEntity.updateFrom(feed); + + feedJpaEntity.getContentList().clear(); // 피드 수정시 기존 영속성 컨텍스트 내 엔티티 연결 제거 + applyFeedContents(feed, feedJpaEntity); - List contentJpaEntities = feed.getContentList().stream() + feedTagJpaRepository.deleteAllByFeedJpaEntity(feedJpaEntity); // 피드 수정시 기존 피드의 모든 FeedTag 매핑 row 삭제 + applyFeedTags(feed, feedJpaEntity); + + return feedJpaEntity.getPostId(); + } + + private void applyFeedContents(Feed feed, FeedJpaEntity feedJpaEntity) { + if (feed.getContentList().isEmpty()) return; + List contents = feed.getContentList().stream() .map(content -> contentMapper.toJpaEntity(content, feedJpaEntity)) .toList(); - - contentJpaEntities.forEach(feedJpaEntity.getContentList()::add); + feedJpaEntity.getContentList().addAll(contents); } - private void saveFeedTags(Feed feed, FeedJpaEntity feedJpaEntity) { + private void applyFeedTags(Feed feed, FeedJpaEntity feedJpaEntity) { if (feed.getTagList().isEmpty()) return; - for (Tag tag : feed.getTagList()) { TagJpaEntity tagJpaEntity = tagJpaRepository.findByValue(tag.getValue()) .orElseThrow(() -> new EntityNotFoundException(TAG_NOT_FOUND)); @@ -86,5 +106,4 @@ private void saveFeedTags(Feed feed, FeedJpaEntity feedJpaEntity) { } } - } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java index 16c932218..f8d37dcbd 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java @@ -1,7 +1,16 @@ package konkuk.thip.feed.adapter.out.persistence.repository.FeedTag; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface FeedTagJpaRepository extends JpaRepository{ + + @Modifying + @Query("DELETE FROM FeedTagJpaEntity ft WHERE ft.feedJpaEntity = :feedJpaEntity") + void deleteAllByFeedJpaEntity(@Param("feedJpaEntity") FeedJpaEntity feedJpaEntity); + } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java index 88c778a9b..176244ecd 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java @@ -2,9 +2,16 @@ import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface TagJpaRepository extends JpaRepository{ Optional findByValue(String value); + + @Query("SELECT ft.tagJpaEntity FROM FeedTagJpaEntity ft WHERE ft.feedJpaEntity.postId = :feedId") + List findAllByFeedId(@Param("feedId") Long feedId); + } diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java new file mode 100644 index 000000000..d94530a60 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.application.port.in.dto.FeedUpdateCommand; + +public interface FeedUpdateUseCase { + Long updateFeed(FeedUpdateCommand command); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java index 6d0ba81c9..a5922a6fa 100644 --- a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java @@ -10,8 +10,6 @@ public record FeedCreateCommand( Boolean isPublic, - String category, - List tagList, Long userId diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedUpdateCommand.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedUpdateCommand.java new file mode 100644 index 000000000..758a3e91f --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedUpdateCommand.java @@ -0,0 +1,20 @@ +package konkuk.thip.feed.application.port.in.dto; + +import java.util.List; + +public record FeedUpdateCommand( + + String contentBody, + + Boolean isPublic, + + List tagList, + + List remainImageUrls, + + Long userId, + + Long feedId +) +{ +} diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index a6a9c1930..ffbdc3621 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -5,4 +5,6 @@ public interface FeedCommandPort { Long save(Feed feed); + Long update(Feed feed); + Feed findById(Long id); } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java index 10edae5cd..e6fd35c16 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java @@ -10,7 +10,6 @@ import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.application.port.out.S3CommandPort; import konkuk.thip.feed.domain.Feed; -import konkuk.thip.room.application.port.out.RoomCommandPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,33 +22,30 @@ public class FeedCreateService implements FeedCreateUseCase { private final S3CommandPort s3CommandPort; - private final RoomCommandPort roomCommandPort; private final BookCommandPort bookCommandPort; private final FeedCommandPort feedCommandPort; private final BookApiQueryPort bookApiQueryPort; @Override @Transactional + //TODO 추후 예외 발생시 이미 s3에 업로드된 이미지 삭제 방식 논의 public Long createFeed(FeedCreateCommand command, List images) { // 1. 피드 생성 비지니스 정책 검증 - Feed.validateCategoryAndTags(command.category(), command.tagList()); + Feed.validateTags(command.tagList()); Feed.validateImageCount(images != null ? images.size() : 0); - - // 2. Category 검증 및 조회 - validateCategoryAndTagList(command.category(), command.tagList()); - - // 3. Book 검증 및 조회 + // 2. Book 검증 및 조회 Long targetBookId = findOrCreateBookByIsbn(command.isbn()); - // 4. 이미지 업로드 - List imageUrls = (images == null || images.isEmpty()) - ? List.of() - : s3CommandPort.uploadImages(images); - - // 5. Feed 생성 및 저장 (Content도 함께 생성 및 저장 애그리거트 루트인 Feed가 생성책임 가지고있음) + // 3. 이미지 업로드 + List imageUrls = null; try { + imageUrls = (images == null || images.isEmpty()) + ? List.of() + : s3CommandPort.uploadImages(images); + + // 4. Feed 생성 및 저장 (Content도 함께 생성 및 저장 애그리거트 루트인 Feed가 생성책임 가지고있음) Feed feed = Feed.withoutId( command.contentBody(), command.userId(), @@ -61,25 +57,13 @@ public Long createFeed(FeedCreateCommand command, List images) { return feedCommandPort.save(feed); } catch (Exception e) { - if (imageUrls != null) { + if (imageUrls != null && !imageUrls.isEmpty()) { s3CommandPort.deleteImages(imageUrls); } throw e; } } - // TODO: 카테고리, 태그 관계가 명확해지면 카테고리 내의 도메인에서 검증하도록 리팩토링 예정 - private void validateCategoryAndTagList(String categoryValue, List tagList) { - - boolean hasCategoryAndTags = categoryValue != null && !categoryValue.trim().isEmpty() - && tagList != null && !tagList.isEmpty(); - - // Category 검증 및 조회 - if(hasCategoryAndTags) { roomCommandPort.findCategoryByValue(categoryValue); } - - // TODO: Category로 tagList 검증 - } - /** * ISBN으로 책을 조회하고, 없으면 외부 API(Naver)에서 상세 정보를 조회해 새로 저장 후 ID 반환 */ diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java new file mode 100644 index 000000000..182f236c8 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java @@ -0,0 +1,70 @@ +package konkuk.thip.feed.application.service; + +import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; +import konkuk.thip.feed.application.port.in.dto.FeedUpdateCommand; +import konkuk.thip.feed.application.port.out.FeedCommandPort; +import konkuk.thip.feed.application.port.out.S3CommandPort; +import konkuk.thip.feed.domain.Content; +import konkuk.thip.feed.domain.Feed; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FeedUpdateService implements FeedUpdateUseCase { + + private final S3CommandPort s3CommandPort; + private final FeedCommandPort feedCommandPort; + + @Override + @Transactional + public Long updateFeed(FeedUpdateCommand command) { + + //1. 유효성 검증 + Feed.validateTags(command.tagList()); + Feed.validateImageCount(command.remainImageUrls() != null ? command.remainImageUrls().size() : 0); + + // 2. 피드 조회 + Feed feed = feedCommandPort.findById(command.feedId()); + + // 3. 도메인 내에서 내부 상태 변경 및 검증 + applyPartialFeedUpdate(feed, command); + + // 4. 업데이트 + return feedCommandPort.update(feed); + } + + private void applyPartialFeedUpdate(Feed feed, FeedUpdateCommand command) { + + if (command.remainImageUrls() != null) { + feed.updateImages(command.userId(), command.remainImageUrls()); + } + if (command.contentBody() != null) { + feed.updateContent(command.userId(), command.contentBody()); + } + if (command.isPublic() != null) { + feed.updateVisibility(command.userId(), command.isPublic()); + } + if (command.tagList() != null) { + feed.updateTags(command.userId(), command.tagList()); + } + } + + //TODO 추후 이벤트 기반으로 트랜잭션 커밋후 S3 삭제하도록 리펙토링 or 사용하지 않는 이미지 배치 삭제방식 논의 + private void handleFeedImageDelete(Feed feed, List remainImageUrls) { + List oldImageUrls = feed.getContentList().stream() + .map(Content::getContentUrl) + .filter(url -> url != null && !url.isBlank()) + .toList(); + + List toDelete = oldImageUrls.stream() + .filter(url -> !remainImageUrls.contains(url)) + .toList(); + if (!toDelete.isEmpty()) { + s3CommandPort.deleteImages(toDelete); + } + } +} diff --git a/src/main/java/konkuk/thip/feed/domain/Feed.java b/src/main/java/konkuk/thip/feed/domain/Feed.java index 3fe0be1e5..1f50c9e5c 100644 --- a/src/main/java/konkuk/thip/feed/domain/Feed.java +++ b/src/main/java/konkuk/thip/feed/domain/Feed.java @@ -6,7 +6,9 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static konkuk.thip.common.exception.code.ErrorCode.*; @@ -36,11 +38,15 @@ public class Feed extends BaseDomainEntity { private List tagList; - private List contentList; + @Builder.Default + private List contentList = new ArrayList<>(); public static Feed withoutId(String content, Long creatorId, Boolean isPublic, Long targetBookId, List tagValues, List imageUrls) { + validateTags(tagValues); + validateImageCount(imageUrls != null ? imageUrls.size() : 0); + return Feed.builder() .id(null) .content(content) @@ -56,42 +62,75 @@ public static Feed withoutId(String content, Long creatorId, Boolean isPublic, L } private static List convertToContentList(List imageUrls) { - if (imageUrls == null) return List.of(); - + if (imageUrls == null) return new ArrayList<>(); return imageUrls.stream() .filter(url -> url != null && !url.isBlank()) .map(url -> Content.builder().contentUrl(url).build()) .collect(Collectors.toList()); } - public static void validateCategoryAndTags(String category, List tagList) { - - // 둘 다 없으면 카테고리도 태그도 없는 새 게시글 (예외 상황 아님) - boolean categoryEmpty = (category == null || category.trim().isEmpty()); + public static void validateTags(List tagList) { boolean tagListEmpty = (tagList == null || tagList.isEmpty()); - // 둘 중 하나만 입력된 경우 - if (categoryEmpty ^ tagListEmpty) { - throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("카테고리와 태그는 모두 입력되거나 모두 비워져야 합니다.")); - } - // 태그가 있는 경우, 개수 최대 5개 제한 if (!tagListEmpty && tagList.size() > 5) { - throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("태그는 최대 5개까지 입력할 수 있습니다.")); + throw new InvalidStateException(INVALID_FEED_COMMAND, new IllegalArgumentException("태그는 최대 5개까지 입력할 수 있습니다.")); } // 태그 중복 체크 if (!tagListEmpty) { long distinctCount = tagList.stream().distinct().count(); if (distinctCount != tagList.size()) { - throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("태그는 중복 될 수 없습니다.")); + throw new InvalidStateException(INVALID_FEED_COMMAND, new IllegalArgumentException("태그는 중복 될 수 없습니다.")); } } } public static void validateImageCount(int imageSize) { if (imageSize > 3) { - throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("이미지는 최대 3개까지 업로드할 수 있습니다.")); + throw new InvalidStateException(INVALID_FEED_COMMAND, new IllegalArgumentException("이미지는 최대 3개까지 업로드할 수 있습니다.")); + } + } + + public void validateCreator(Long userId) { + if (!this.creatorId.equals(userId)) { + throw new InvalidStateException(FEED_UPDATE_FORBIDDEN); + } + } + + public void updateContent(Long userId, String newContent) { + validateCreator(userId); + this.content = newContent; + } + + public void updateVisibility(Long userId, Boolean isPublic) { + validateCreator(userId); + this.isPublic = isPublic; + } + + public void updateTags(Long userId, List newTagValues) { + validateCreator(userId); + validateTags(newTagValues); + this.tagList = Tag.fromList(newTagValues); // Tag.from(...) 등으로 변환 + } + + public void updateImages(Long userId, List newImageUrls) { + validateCreator(userId); + validateImageCount(newImageUrls.size()); + validateOwnsImages(newImageUrls); + + this.contentList = convertToContentList(newImageUrls); + } + + public void validateOwnsImages(List candidateImageUrls) { + Set myImageUrls = this.getContentList().stream() + .map(Content::getContentUrl) + .filter(url -> url != null && !url.isBlank()) + .collect(Collectors.toSet()); + for (String url : candidateImageUrls) { + if (!myImageUrls.contains(url)) { + throw new InvalidStateException(INVALID_FEED_COMMAND, new IllegalArgumentException("해당 이미지는 이 피드에 존재하지 않습니다: " + url)); + } } } diff --git a/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java b/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java index 35fce5ada..ccb8710c8 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java +++ b/src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java @@ -21,11 +21,11 @@ public abstract class PostJpaEntity extends BaseJpaEntity { private Long postId; @Column(length = 6100, nullable = false) - private String content; + protected String content; - private Integer likeCount = 0; + protected Integer likeCount = 0; - private Integer commentCount = 0; + protected Integer commentCount = 0; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java index b6f4a7656..704195ffb 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java @@ -9,7 +9,6 @@ import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; import konkuk.thip.room.application.port.out.RoomCommandPort; -import konkuk.thip.room.domain.Category; import konkuk.thip.room.domain.Room; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -56,13 +55,6 @@ public Long save(Room room) { return roomJpaRepository.save(roomJpaEntity).getRoomId(); } - @Override - public Category findCategoryByValue(String value) { - CategoryJpaEntity categoryJpaEntity = categoryJpaRepository.findByValue(value).orElseThrow( - () -> new EntityNotFoundException(CATEGORY_NOT_FOUND)); - return Category.from(categoryJpaEntity.getValue()); - } - @Override public void update(Room room) { RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(room.getId()).orElseThrow( diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java index b0ed3468c..75071b2df 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java @@ -19,7 +19,5 @@ default Room getByIdOrThrow(Long id) { Long save(Room room); - Category findCategoryByValue(String value); - void update(Room room); } diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index 08a38c86e..e03ddb30a 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -2,6 +2,9 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; @@ -16,6 +19,8 @@ import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; public class TestEntityFactory { @@ -169,4 +174,52 @@ public static TagJpaEntity createTag(CategoryJpaEntity category,String value) { .value(value) .build(); } + + public static FeedJpaEntity createFeed(UserJpaEntity user, BookJpaEntity book, boolean isPublic) { + + return FeedJpaEntity.builder() + .content("기본 피드 본문입니다.") + .isPublic(isPublic) + .likeCount(0) + .commentCount(0) + .reportCount(0) + .userJpaEntity(user) + .bookJpaEntity(book) + .contentList(new ArrayList<>()) + .build(); + } + + public static FeedTagJpaEntity createFeedTagMapping(FeedJpaEntity feed, TagJpaEntity tag) { + return FeedTagJpaEntity.builder() + .feedJpaEntity(feed) + .tagJpaEntity(tag) + .build(); + } + + + public static FeedJpaEntity createFeedWithContents(UserJpaEntity user, BookJpaEntity book, List imageUrls, boolean isPublic) { + + FeedJpaEntity feed = FeedJpaEntity.builder() + .content("이미지 포함 피드") + .isPublic(isPublic) + .likeCount(0) + .commentCount(0) + .reportCount(0) + .userJpaEntity(user) + .bookJpaEntity(book) + .contentList(new ArrayList<>()) + .build(); + + List contents = imageUrls.stream() + .map(url -> ContentJpaEntity.builder() + .contentUrl(url) + .postJpaEntity(feed) + .build()) + .toList(); + + feed.getContentList().addAll(contents); + return feed; + } + + } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java index 33ed9fa47..b755cdf51 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java @@ -18,7 +18,7 @@ import java.util.Map; import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; -import static konkuk.thip.common.exception.code.ErrorCode.INVALID_FEED_CREATE; +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_FEED_COMMAND; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -55,7 +55,7 @@ private void assertBadRequest_InvalidFeedCreate(Map request, Str .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) .requestAttr("userId", 1L)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(INVALID_FEED_CREATE.getCode())) + .andExpect(jsonPath("$.code").value(INVALID_FEED_COMMAND.getCode())) .andExpect(jsonPath("$.message", containsString(message))); } @@ -102,24 +102,8 @@ void missing_is_public() throws Exception { } @Nested - @DisplayName("카테고리/태그 입력 불일치 검증") - class CategoryTagValidation { - - @Test - @DisplayName("카테고리만 있고 태그가 없을 때 400 반환") - void onlyCategory() throws Exception { - Map req = buildValidRequest(); - req.put("tagList", List.of()); - assertBadRequest_InvalidFeedCreate(req, "카테고리와 태그는 모두 입력되거나 모두 비워져야 합니다."); - } - - @Test - @DisplayName("태그만 있고 카테고리가 없을 때 400 반환") - void onlyTags() throws Exception { - Map req = buildValidRequest(); - req.put("category", null); - assertBadRequest_InvalidFeedCreate(req, "카테고리와 태그는 모두 입력되거나 모두 비워져야 합니다."); - } + @DisplayName("태그 입력 불일치 검증") + class TagValidation { @Test @DisplayName("태그가 6개 이상이면 400 반환") @@ -171,7 +155,7 @@ void tooManyImages() throws Exception { ); result.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(INVALID_FEED_CREATE.getCode())) + .andExpect(jsonPath("$.code").value(INVALID_FEED_COMMAND.getCode())) .andExpect(jsonPath("$.message",containsString("이미지는 최대 3개까지 업로드할 수 있습니다."))); } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java new file mode 100644 index 000000000..91dbb2406 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java @@ -0,0 +1,216 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.config.TestS3MockConfig; +import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.Content.ContentJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedTag.FeedTagJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.Tag.TagJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +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; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@Import(TestS3MockConfig.class) +@DisplayName("[통합] 피드 수정 api 통합 테스트") +class FeedUpdateAPITest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private TagJpaRepository tagJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private FeedTagJpaRepository feedTagJpaRepository; + @Autowired private ContentJpaRepository contentJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity book; + private FeedJpaEntity feed; + private TagJpaEntity tag1; + private TagJpaEntity tag2; + private TagJpaEntity tag3; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + tag1 = tagJpaRepository.save(TestEntityFactory.createTag(category, "소설추천")); + tag2 = tagJpaRepository.save(TestEntityFactory.createTag(category, "책추천")); + tag3 = tagJpaRepository.save(TestEntityFactory.createTag(category, "오늘의책")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + } + + @Test + @DisplayName("여러 태그가 있는 피드에서 태그를 수정하면 최종 태그로 반영되어 저장된다.") + void updateTaggedFeed_shouldUpdateTagsCorrectly() throws Exception { + + // given + Long feedId = feed.getPostId(); + + // 기존 태그 3개 연관 + List existingTags = List.of(tag1, tag2, tag3); + List mappings = existingTags.stream() + .map(tag -> TestEntityFactory.createFeedTagMapping(feed, tag)) + .toList(); + + feedTagJpaRepository.saveAll(mappings); + + // 수정 요청 + Map request = new HashMap<>(); + request.put("contentBody", "태그 갱신 테스트"); + request.put("isPublic", false); + request.put("tagList", List.of("소설추천", "오늘의책")); // 하나 제거됨 + + // when + ResultActions result = mockMvc.perform(patch("/feeds/{feedId}", feedId) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()); + long tagCount = feedTagJpaRepository.findAll().stream() + .filter(ft -> ft.getFeedJpaEntity().getPostId().equals(feedId)) + .count(); + assertThat(tagCount).isEqualTo(2); + } + + @Test + @DisplayName("이미지가 여러 개인 피드에서 이미지 일부만 유지하도록 수정할 수 있다.") + void updateImageFeed_shouldRetainSomeImagesOnly() throws Exception { + + // given + List originalImages = List.of( + "https://s3-mock/image-1.jpg", + "https://s3-mock/image-2.jpg", + "https://s3-mock/image-3.jpg" + ); + + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeedWithContents(user, book, originalImages, true)); + Long feedId = feed.getPostId(); + + // 수정 요청: 이미지 1개만 유지 + Map request = new HashMap<>(); + request.put("contentBody", "이미지 삭제 테스트"); + request.put("isPublic", false); + request.put("remainImageUrls", List.of("https://s3-mock/image-2.jpg")); // 나머지 삭제 + + // when + ResultActions result = mockMvc.perform(patch("/feeds/{feedId}", feedId) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()); + FeedJpaEntity updatedFeed = feedJpaRepository.findById(feedId).orElseThrow(); + assertThat(updatedFeed.getContentList()).hasSize(1); + assertThat(updatedFeed.getContentList().get(0).getContentUrl()).isEqualTo("https://s3-mock/image-2.jpg"); + List contentRows = contentJpaRepository.findAll().stream() + .filter(c -> c.getPostJpaEntity().getPostId().equals(feedId)) + .toList(); + assertThat(contentRows).hasSize(1); + assertThat(contentRows.get(0).getContentUrl()).isEqualTo("https://s3-mock/image-2.jpg"); + } + + @Test + @DisplayName("피드의 내용, 공개 여부, 태그, 이미지 전체를 모두 수정할 수 있다.") + void updateFeedWithAllFields_shouldModifyEverythingCorrectly() throws Exception { + + // given + // 기존 이미지 3개 + List originalImages = List.of( + "https://s3-mock/image-1.jpg", + "https://s3-mock/image-2.jpg", + "https://s3-mock/image-3.jpg" + ); + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeedWithContents(user, book, originalImages, true)); + Long feedId = feed.getPostId(); + + // 기존 태그 3개 매핑 + List existingTags = List.of(tag1, tag2, tag3); + List tagMappings = existingTags.stream() + .map(tag -> TestEntityFactory.createFeedTagMapping(feed, tag)) + .collect(Collectors.toList()); + feedTagJpaRepository.saveAll(tagMappings); + + // 수정 요청: 태그 일부 삭제 & 이미지 일부 삭제 & 본문 변경 & 공개 여부 변경 + Map request = new HashMap<>(); + request.put("contentBody", "전부 수정되는 피드 테스트"); + request.put("isPublic", false); + request.put("remainImageUrls", List.of("https://s3-mock/image-2.jpg")); // 이미지 1개 유지 + request.put("tagList", List.of("소설추천", "오늘의책")); // 태그 2개만 남김 + + // when + ResultActions result = mockMvc.perform(patch("/feeds/{feedId}", feedId) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()); + + FeedJpaEntity updated = feedJpaRepository.findById(feedId).orElseThrow(); + // 1. 본문 + assertThat(updated.getContent()).isEqualTo("전부 수정되는 피드 테스트"); + // 2. 공개 여부 + assertThat(updated.getIsPublic()).isFalse(); + // 3. 이미지 + assertThat(updated.getContentList()).hasSize(1); + assertThat(updated.getContentList().get(0).getContentUrl()).isEqualTo("https://s3-mock/image-2.jpg"); + List contentRows = contentJpaRepository.findAll().stream() + .filter(c -> c.getPostJpaEntity().getPostId().equals(feedId)) + .toList(); + assertThat(contentRows).hasSize(1); + assertThat(contentRows.get(0).getContentUrl()).isEqualTo("https://s3-mock/image-2.jpg"); + // 4. 태그 갯수 + long tagCount = feedTagJpaRepository.findAll().stream() + .filter(tag -> tag.getFeedJpaEntity().getPostId().equals(feedId)) + .count(); + assertThat(tagCount).isEqualTo(2); + } + +} diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java new file mode 100644 index 000000000..1b466ed7b --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java @@ -0,0 +1,204 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.Tag.TagJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.*; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[단위] 피드 수정 api controller 단위 테스트") +class FeedUpdateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private TagJpaRepository tagJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + + private Long savedFeedId; + private Long creatorUserId; + + @BeforeEach + void setUp() { + + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + tagJpaRepository.save(TestEntityFactory.createTag(category, "소설추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category, "책추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category, "오늘의책")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + savedFeedId = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)).getPostId(); + creatorUserId = user.getUserId(); + } + + private Map buildValidUpdateRequest() { + Map request = new HashMap<>(); + request.put("contentBody", "수정된 테스트 콘텐츠"); + request.put("isPublic", true); + request.put("tagList", List.of("책추천", "소설추천")); + request.put("remainImageUrls", List.of()); + return request; + } + + private void assertBadRequest(int expectedCode, Map request, String message) throws Exception { + mockMvc.perform(patch("/feeds/1") + .requestAttr("userId", 100L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(request)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(expectedCode)) + .andExpect(jsonPath("$.message", containsString(message))); + } + + @Nested + @DisplayName("피드 관련 검증") + class BasicValidation { + + @Test + @DisplayName("존재하지 않는 피드를 수정하려는 경우 404 반환") + void updateNonExistentFeed() throws Exception { + Map req = buildValidUpdateRequest(); + mockMvc.perform(patch("/feeds/99999") + .requestAttr("userId", 100L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(FEED_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message", containsString("존재하지 않는 FEED 입니다."))); + } + + @Test + @DisplayName("피드 생성자가 아닌 유저가 수정하려는 경우 403 반환") + void unauthorizedFeedEdit() throws Exception { + Map req = buildValidUpdateRequest(); + mockMvc.perform(patch("/feeds/" + savedFeedId) + .requestAttr("userId",100L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(FEED_UPDATE_FORBIDDEN.getCode())) + .andExpect(jsonPath("$.message", containsString("피드 수정 권한이 없습니다."))); + } + + } + + @Nested + @DisplayName("태그 검증") + class TagValidation { + + @Test + @DisplayName("태그리스트 중 존재하지 않는 태그가 있을 때 400 반환") + void invalidTagNames() throws Exception { + Map req = buildValidUpdateRequest(); + req.put("tagList", List.of("에세이", "휴식", "힐링")); + mockMvc.perform(patch("/feeds/" + savedFeedId) + .requestAttr("userId",creatorUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(TAG_NAME_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message", containsString("일치하는 태그 이름이 없습니다"))) + .andExpect(jsonPath("$.message", containsString("에세이"))) + .andExpect(jsonPath("$.message", containsString("휴식"))) + .andExpect(jsonPath("$.message", containsString("힐링"))); + } + + @Test + @DisplayName("태그가 5개 초과일 경우 400 반환") + void tooManyTags() throws Exception { + Map req = buildValidUpdateRequest(); + req.put("tagList", List.of("t1","t2","t3","t4","t5","t6")); + assertBadRequest(INVALID_FEED_COMMAND.getCode(), req, "태그는 최대 5개까지 입력할 수 있습니다."); + } + + @Test + @DisplayName("태그가 중복되어 있을 경우 400 반환") + void duplicatedTags() throws Exception { + Map req = buildValidUpdateRequest(); + req.put("tagList", List.of("중복", "중복")); + assertBadRequest(INVALID_FEED_COMMAND.getCode(), req, "태그는 중복 될 수 없습니다."); + } + + } + + @Nested + @DisplayName("이미지 검증") + class ImageValidation { + + @Test + @DisplayName("이미지가 3개 초과되면 400 반환") + void tooManyImages() throws Exception { + Map req = buildValidUpdateRequest(); + req.put("remainImageUrls", + List.of("https://s3.../profile1.png", "https://s3.../profile2.png", + "https://s3.../profile3.png", "https://s3.../profile4.png")); + mockMvc.perform(patch("/feeds/" + savedFeedId) + .requestAttr("userId",creatorUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(INVALID_FEED_COMMAND.getCode())) + .andExpect(jsonPath("$.message",containsString("이미지는 최대 3개까지 업로드할 수 있습니다."))); + } + + @Test + @DisplayName("이미지 url이 잘못되었을 때 400 반환") + void invalidImageUrl() throws Exception { + Map req = buildValidUpdateRequest(); + req.put("remainImageUrls", List.of("https://s3.../profile1.png")); + mockMvc.perform(patch("/feeds/" + savedFeedId) + .requestAttr("userId",creatorUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(INVALID_FEED_COMMAND.getCode())) + .andExpect(jsonPath("$.message", containsString("해당 이미지는 이 피드에 존재하지 않습니다"))); + } + } + +}