diff --git a/build.gradle b/build.gradle index 3c0aeb7bb..a537040b6 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //s3 버킷 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java b/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java index 6fe64a38a..57584387b 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java +++ b/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java @@ -36,7 +36,7 @@ public class BookJpaEntity extends BaseJpaEntity { @Column(name = "page_count") private Integer pageCount; - @Column(length = 1000) + @Column(length = 3000) private String description; public void changePageCount(Integer pageCount) { 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 6d94870e1..9a8bbce9b 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -117,11 +117,20 @@ public enum ErrorCode implements ResponseCode { CATEGORY_NOT_MATCH(HttpStatus.BAD_REQUEST, 150001, "일치하는 카테고리 이름이 없습니다."), /** - * 160000 : Feed error + * 160000 : Feed,Tag error */ FEED_NOT_FOUND(HttpStatus.NOT_FOUND, 160000, "존재하지 않는 FEED 입니다."), - TAG_NAME_NOT_MATCH(HttpStatus.BAD_REQUEST, 160001, "일치하는 태그 이름이 없습니다.") + TAG_NAME_NOT_MATCH(HttpStatus.BAD_REQUEST, 160001, "일치하는 태그 이름이 없습니다."), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, 160002, "존재하지 않는 TAG 입니다."), + INVALID_FEED_CREATE(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성 요청 입니다."), + /** + * 170000 : Image File error + */ + EMPTY_FILE_EXCEPTION(HttpStatus.BAD_REQUEST, 170001, "업로드하려는 이미지가 비어있습니다."), + EXCEPTION_ON_IMAGE_UPLOAD(HttpStatus.BAD_REQUEST, 170002, "이미지 업로드에 실패하였습니다."), + INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, 170003, "올바르지 않은 파일 형식입니다."), + IO_EXCEPTION_ON_IMAGE_DELETE(HttpStatus.BAD_REQUEST, 170004, "파일 삭제에 실패하였습니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/konkuk/thip/config/S3Config.java b/src/main/java/konkuk/thip/config/S3Config.java new file mode 100644 index 000000000..069c807e9 --- /dev/null +++ b/src/main/java/konkuk/thip/config/S3Config.java @@ -0,0 +1,34 @@ +package konkuk.thip.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + //accessKey, secretKey, region 값으로 S3에 접근 가능한 객체 등록 + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return (AmazonS3Client) AmazonS3ClientBuilder + .standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} 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 6a0bc478f..9e6c8bb6e 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 @@ -1,10 +1,31 @@ package konkuk.thip.feed.adapter.in.web; +import jakarta.validation.Valid; +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.application.port.in.FeedCreateUseCase; 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.multipart.MultipartFile; +import java.util.List; + +@Slf4j @RestController @RequiredArgsConstructor public class FeedCommandController { + private final FeedCreateUseCase feedCreateUseCase; + + @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))); + } } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/DummyRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/DummyRequest.java deleted file mode 100644 index 415dcbd49..000000000 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/request/DummyRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.feed.adapter.in.web.request; - -import lombok.Getter; - -@Getter -public class DummyRequest { -} 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 new file mode 100644 index 000000000..f695ea8bf --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java @@ -0,0 +1,34 @@ +package konkuk.thip.feed.adapter.in.web.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import konkuk.thip.feed.application.port.in.dto.FeedCreateCommand; + +import java.util.List; + +public record FeedCreateRequest( + + @NotBlank(message = "ISBN은 필수입니다.") + String isbn, + + @NotBlank(message = "콘텐츠 내용은 필수입니다.") + String contentBody, + + @NotNull(message = "방 공개 설정 여부는 필수입니다.") + Boolean isPublic, + + String category, + + List tagList +) { + public FeedCreateCommand toCommand(Long userId) { + return new FeedCreateCommand( + isbn, + contentBody, + isPublic, + category, + tagList, + userId + ); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/DummyResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/DummyResponse.java deleted file mode 100644 index 6e34b6d9e..000000000 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/response/DummyResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.feed.adapter.in.web.response; - -import lombok.Getter; - -@Getter -public class DummyResponse { -} 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 new file mode 100644 index 000000000..5c8dce218 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java @@ -0,0 +1,7 @@ +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/post/adapter/out/jpa/ContentJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/ContentJpaEntity.java similarity index 86% rename from src/main/java/konkuk/thip/post/adapter/out/jpa/ContentJpaEntity.java rename to src/main/java/konkuk/thip/feed/adapter/out/jpa/ContentJpaEntity.java index 65aec4ae2..b574f5c3d 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/jpa/ContentJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/ContentJpaEntity.java @@ -1,7 +1,8 @@ -package konkuk.thip.post.adapter.out.jpa; +package konkuk.thip.feed.adapter.out.jpa; import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; +import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import lombok.*; 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 2dcd983d1..26e140844 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 @@ -10,6 +10,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + @Entity @Table(name = "feeds") @DiscriminatorValue("FEED") @@ -27,11 +29,15 @@ public class FeedJpaEntity extends PostJpaEntity { @JoinColumn(name = "book_id", nullable = false) private BookJpaEntity bookJpaEntity; + @OneToMany(mappedBy = "postJpaEntity", cascade = CascadeType.ALL, orphanRemoval = true) + private List contentList; + @Builder - public FeedJpaEntity(String content, Integer likeCount, Integer commentCount, UserJpaEntity userJpaEntity, Boolean isPublic, int reportCount, BookJpaEntity bookJpaEntity) { + public FeedJpaEntity(String content, Integer likeCount, Integer commentCount, UserJpaEntity userJpaEntity, Boolean isPublic, int reportCount, BookJpaEntity bookJpaEntity, List contentList) { super(content, likeCount, commentCount, userJpaEntity); this.isPublic = isPublic; this.reportCount = reportCount; this.bookJpaEntity = bookJpaEntity; + this.contentList = contentList; } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/jpa/TagJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/TagJpaEntity.java index 802b35a73..23c4ed2a5 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/jpa/TagJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/TagJpaEntity.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; -import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import lombok.*; @@ -22,10 +21,6 @@ public class TagJpaEntity extends BaseJpaEntity { @Column(name = "tag_value",length = 50, nullable = false) private String value; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id", nullable = false) - private PostJpaEntity postJpaEntity; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id", nullable = false) private CategoryJpaEntity categoryJpaEntity; diff --git a/src/main/java/konkuk/thip/post/adapter/out/mapper/ContentMapper.java b/src/main/java/konkuk/thip/feed/adapter/out/mapper/ContentMapper.java similarity index 86% rename from src/main/java/konkuk/thip/post/adapter/out/mapper/ContentMapper.java rename to src/main/java/konkuk/thip/feed/adapter/out/mapper/ContentMapper.java index f5f20385b..6583fea56 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/mapper/ContentMapper.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/mapper/ContentMapper.java @@ -1,8 +1,8 @@ -package konkuk.thip.post.adapter.out.mapper; +package konkuk.thip.feed.adapter.out.mapper; -import konkuk.thip.post.adapter.out.jpa.ContentJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity; +import konkuk.thip.feed.domain.Content; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; -import konkuk.thip.post.domain.Content; import org.springframework.stereotype.Component; @Component 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 d4106cf3b..dfb48382f 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 @@ -8,6 +8,7 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; @Component @@ -22,6 +23,7 @@ public FeedJpaEntity toJpaEntity(Feed feed, UserJpaEntity userJpaEntity, BookJpa .likeCount(feed.getLikeCount()) .commentCount(feed.getCommentCount()) .bookJpaEntity(bookJpaEntity) + .contentList(new ArrayList<>()) .build(); } 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 4db3af6f8..2bc104b3d 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 @@ -1,15 +1,90 @@ package konkuk.thip.feed.adapter.out.persistence; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.exception.EntityNotFoundException; +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.mapper.ContentMapper; import konkuk.thip.feed.adapter.out.mapper.FeedMapper; +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.feed.application.port.out.FeedCommandPort; +import konkuk.thip.feed.domain.Feed; +import konkuk.thip.feed.domain.Tag; +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 { - private final FeedJpaRepository jpaRepository; - private final FeedMapper userMapper; + private final FeedJpaRepository feedJpaRepository; + private final UserJpaRepository userJpaRepository; + private final BookJpaRepository bookJpaRepository; + private final TagJpaRepository tagJpaRepository; + private final FeedTagJpaRepository feedTagJpaRepository; + private final FeedMapper feedMapper; + private final ContentMapper contentMapper; + + @Override + public Long save(Feed feed) { + + UserJpaEntity userJpaEntity = userJpaRepository.findById(feed.getCreatorId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND) + ); + BookJpaEntity bookJpaEntity = bookJpaRepository.findById(feed.getTargetBookId()).orElseThrow( + () -> new EntityNotFoundException(BOOK_NOT_FOUND) + ); + FeedJpaEntity feedJpaEntity = feedMapper.toJpaEntity(feed,userJpaEntity,bookJpaEntity); + + // Feed 먼저 영속화 → ID 생성 + FeedJpaEntity savedFeed = feedJpaRepository.save(feedJpaEntity); + + // Content가 존재하면 ContentJpaEntity 생성 및 Feed 연관관계 설정 + saveContents(feed, savedFeed); + // 태그가 존재하면 태그 피드 매핑 생성 및 저장 + saveFeedTags(feed, savedFeed); + + return savedFeed.getPostId(); + } + + private void saveContents(Feed feed, FeedJpaEntity feedJpaEntity) { + if (feed.getContentList().isEmpty()) return; + + List contentJpaEntities = feed.getContentList().stream() + .map(content -> contentMapper.toJpaEntity(content, feedJpaEntity)) + .toList(); + + contentJpaEntities.forEach(feedJpaEntity.getContentList()::add); + } + + private void saveFeedTags(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)); + + FeedTagJpaEntity feedTagJpaEntity = FeedTagJpaEntity.builder() + .feedJpaEntity(feedJpaEntity) + .tagJpaEntity(tagJpaEntity) + .build(); + + feedTagJpaRepository.save(feedTagJpaEntity); + } + } + } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index dc1f79dad..eb555cfc2 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -1,6 +1,7 @@ package konkuk.thip.feed.adapter.out.persistence; import konkuk.thip.feed.adapter.out.mapper.FeedMapper; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.feed.application.port.out.FeedQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/S3CommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/S3CommandPersistenceAdapter.java new file mode 100644 index 000000000..10afda4ac --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/S3CommandPersistenceAdapter.java @@ -0,0 +1,28 @@ +package konkuk.thip.feed.adapter.out.persistence; + +import konkuk.thip.feed.adapter.out.s3.S3Service; +import konkuk.thip.feed.application.port.out.S3CommandPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class S3CommandPersistenceAdapter implements S3CommandPort { + + private final S3Service s3Service; + + @Override + public List uploadImages(List images) { + return images.stream() + .map(s3Service::uploadUserImageAndGetUrl) + .toList(); + } + + @Override + public void deleteImages(List imageUrls) { + imageUrls.forEach(s3Service::deleteImageFromS3); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Content/ContentJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Content/ContentJpaRepository.java new file mode 100644 index 000000000..f0793488f --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Content/ContentJpaRepository.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.adapter.out.persistence.repository.Content; + +import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentJpaRepository extends JpaRepository{ +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java similarity index 65% rename from src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedJpaRepository.java rename to src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index 8c50336e2..42439e6e2 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -1,7 +1,7 @@ -package konkuk.thip.feed.adapter.out.persistence; +package konkuk.thip.feed.adapter.out.persistence.repository; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; -public interface FeedJpaRepository extends JpaRepository, FeedQueryRepository{ +public interface FeedJpaRepository extends JpaRepository, FeedQueryRepository { } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java similarity index 64% rename from src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepository.java rename to src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java index 8dd2fd355..c2847f544 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java @@ -1,4 +1,4 @@ -package konkuk.thip.feed.adapter.out.persistence; +package konkuk.thip.feed.adapter.out.persistence.repository; import java.util.Set; diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java similarity index 93% rename from src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepositoryImpl.java rename to src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index cfc7ac939..083b25708 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -1,4 +1,4 @@ -package konkuk.thip.feed.adapter.out.persistence; +package konkuk.thip.feed.adapter.out.persistence.repository; import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.feed.adapter.out.jpa.QFeedJpaEntity; 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 new file mode 100644 index 000000000..16c932218 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.adapter.out.persistence.repository.FeedTag; + +import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedTagJpaRepository extends JpaRepository{ +} 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 new file mode 100644 index 000000000..88c778a9b --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java @@ -0,0 +1,10 @@ +package konkuk.thip.feed.adapter.out.persistence.repository.Tag; + +import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TagJpaRepository extends JpaRepository{ + Optional findByValue(String value); +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/s3/S3Service.java b/src/main/java/konkuk/thip/feed/adapter/out/s3/S3Service.java new file mode 100644 index 000000000..4f2963ebb --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/out/s3/S3Service.java @@ -0,0 +1,147 @@ +package konkuk.thip.feed.adapter.out.s3; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.util.IOUtils; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; // AWS S3 클라이언트 + + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif"); + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + /** + * 유저가 업로드한 이미지 파일을 받아 S3에 저장한 후, public URL을 반환합니다. + * @param image 업로드할 이미지 파일 + * @return S3의 public 이미지 URL + */ + public String uploadUserImageAndGetUrl(MultipartFile image) { + //입력받은 이미지 파일이 빈 파일인지 검증 + if(image.isEmpty() || Objects.isNull(image.getOriginalFilename())){ + throw new BusinessException(EMPTY_FILE_EXCEPTION); + } + return uploadAndReturnUrl(image); + } + + /** + * 이미지 파일 확장자 검증 후 S3 업로드 (실제 업로드는 내부 메서드에서 처리) + */ + private String uploadAndReturnUrl(MultipartFile image) { + this.validateImageFileExtension(image.getOriginalFilename()); + try { + return uploadImageToS3(image); + } catch (IOException e) { + throw new BusinessException(EXCEPTION_ON_IMAGE_UPLOAD); + } + } + + /** + * 이미지 파일 확장자가 jpg, jpeg, png, gif 중 하나인지 검증 + */ + private void validateImageFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf("."); + if (lastDotIndex == -1) { + throw new InvalidStateException(INVALID_FILE_EXTENSION); + } + + String extension = filename.substring(lastDotIndex + 1).toLowerCase(); + + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new InvalidStateException(INVALID_FILE_EXTENSION); + } + } + + /** + * 실제 이미지를 S3에 업로드하고 public URL을 반환 + */ + private String uploadImageToS3(MultipartFile image) throws IOException { + String originalFilename = image.getOriginalFilename(); //원본 파일 명 + String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1); //확장자 명 + + // UUID + 원본 파일명을 합쳐 S3에 저장 (중복 방지) + String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename; + + // 이미지 파일을 바이트 배열로 변환 + InputStream is = image.getInputStream(); + byte[] bytes = IOUtils.toByteArray(is); + + // S3 메타데이터 생성(컨텐츠타입, 길이 등) + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType("image/" + extension); + metadata.setContentLength(bytes.length); + + // 바이트 배열로부터 InputStream 생성 + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); + + try { + // S3에 업로드 + amazonS3.putObject(bucket, s3FileName, byteArrayInputStream, metadata); + + } catch (Exception e) { + throw new BusinessException(EXCEPTION_ON_IMAGE_UPLOAD); + } finally { + byteArrayInputStream.close(); + is.close(); + } + + // 업로드 성공시에 S3 파일의 public URL 반환 + return amazonS3.getUrl(bucket, s3FileName).toString(); + } + + + /** + * S3에 저장된 이미지를 삭제 + * @param imageAddress 이미지 public URL (S3 경로) + */ + @Async + public void deleteImageFromS3(String imageAddress){ + String key = getKeyFromImageAddress(imageAddress); + try{ + amazonS3.deleteObject(new DeleteObjectRequest(bucket, key)); + }catch (Exception e){ + log.error("Failed to delete image from S3. Key: {}, Error: {}", key, e.getMessage(), e); + } + } + + /** + * 이미지 public URL에서 S3 key(파일 경로) 추출 + */ + private String getKeyFromImageAddress(String imageAddress){ + try{ + URL url = new URL(imageAddress); + String decodingKey = URLDecoder.decode(url.getPath(), "UTF-8"); + return decodingKey.substring(1); // 맨 앞의 '/' 제거 + }catch (MalformedURLException | UnsupportedEncodingException e){ + throw new BusinessException(IO_EXCEPTION_ON_IMAGE_DELETE); + } + } +} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/DummyUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/DummyUseCase.java deleted file mode 100644 index da0bfe080..000000000 --- a/src/main/java/konkuk/thip/feed/application/port/in/DummyUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package konkuk.thip.feed.application.port.in; - -public interface DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedCreateUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedCreateUseCase.java new file mode 100644 index 000000000..9aa7ce411 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedCreateUseCase.java @@ -0,0 +1,10 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.application.port.in.dto.FeedCreateCommand; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface FeedCreateUseCase { + Long createFeed(FeedCreateCommand command, List images); +} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyCommand.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyCommand.java deleted file mode 100644 index 9b090d8ed..000000000 --- a/src/main/java/konkuk/thip/feed/application/port/in/dto/DummyCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package konkuk.thip.feed.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyCommand { - -} 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 new file mode 100644 index 000000000..6d0ba81c9 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java @@ -0,0 +1,20 @@ +package konkuk.thip.feed.application.port.in.dto; + +import java.util.List; + +public record FeedCreateCommand( + + String isbn, + + String contentBody, + + Boolean isPublic, + + String category, + + List tagList, + + Long userId +) +{ +} 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 b00b6c190..a6a9c1930 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 @@ -1,6 +1,8 @@ package konkuk.thip.feed.application.port.out; -public interface FeedCommandPort { +import konkuk.thip.feed.domain.Feed; +public interface FeedCommandPort { + Long save(Feed feed); } diff --git a/src/main/java/konkuk/thip/feed/application/port/out/S3CommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/S3CommandPort.java new file mode 100644 index 000000000..29a057c1c --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/out/S3CommandPort.java @@ -0,0 +1,10 @@ +package konkuk.thip.feed.application.port.out; + +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface S3CommandPort { + List uploadImages(List images); + void deleteImages(List imageUrls); +} diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java new file mode 100644 index 000000000..10edae5cd --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java @@ -0,0 +1,111 @@ +package konkuk.thip.feed.application.service; + +import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; +import konkuk.thip.book.application.port.out.BookApiQueryPort; +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.feed.application.port.in.FeedCreateUseCase; +import konkuk.thip.feed.application.port.in.dto.FeedCreateCommand; +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; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Service +@RequiredArgsConstructor +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 + public Long createFeed(FeedCreateCommand command, List images) { + + // 1. 피드 생성 비지니스 정책 검증 + Feed.validateCategoryAndTags(command.category(), command.tagList()); + Feed.validateImageCount(images != null ? images.size() : 0); + + + // 2. Category 검증 및 조회 + validateCategoryAndTagList(command.category(), command.tagList()); + + // 3. Book 검증 및 조회 + Long targetBookId = findOrCreateBookByIsbn(command.isbn()); + + // 4. 이미지 업로드 + List imageUrls = (images == null || images.isEmpty()) + ? List.of() + : s3CommandPort.uploadImages(images); + + // 5. Feed 생성 및 저장 (Content도 함께 생성 및 저장 애그리거트 루트인 Feed가 생성책임 가지고있음) + try { + Feed feed = Feed.withoutId( + command.contentBody(), + command.userId(), + command.isPublic(), + targetBookId, + command.tagList(), + imageUrls + ); + return feedCommandPort.save(feed); + + } catch (Exception e) { + if (imageUrls != null) { + 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 반환 + */ + private Long findOrCreateBookByIsbn(String isbn) { + try { + Book existing = bookCommandPort.findByIsbn(isbn); + return existing.getId(); + } catch (EntityNotFoundException e) { + return saveNewBookWithFromExternalApi(isbn); + } + } + + /** + * 외부 API(Naver)를 통해 상세 책 정보를 조회하고 Book 도메인으로 저장 + */ + private Long saveNewBookWithFromExternalApi(String isbn) { + NaverDetailBookParseResult detailBookByKeyword = bookApiQueryPort.findDetailBookByIsbn(isbn); + Book newBook = Book.withoutId( + detailBookByKeyword.title(), + detailBookByKeyword.isbn(), + detailBookByKeyword.author(), + false, // TODO : 추후 BestSeller 도입 시 로직 수정 + detailBookByKeyword.publisher(), + detailBookByKeyword.imageUrl(), + null, + detailBookByKeyword.description()); + return bookCommandPort.save(newBook); + } +} diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedService.java b/src/main/java/konkuk/thip/feed/application/service/FeedService.java deleted file mode 100644 index fab2de654..000000000 --- a/src/main/java/konkuk/thip/feed/application/service/FeedService.java +++ /dev/null @@ -1,11 +0,0 @@ -package konkuk.thip.feed.application.service; - -import konkuk.thip.feed.application.port.in.DummyUseCase; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class FeedService implements DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/post/domain/Content.java b/src/main/java/konkuk/thip/feed/domain/Content.java similarity index 89% rename from src/main/java/konkuk/thip/post/domain/Content.java rename to src/main/java/konkuk/thip/feed/domain/Content.java index 58490831f..2b161c8db 100644 --- a/src/main/java/konkuk/thip/post/domain/Content.java +++ b/src/main/java/konkuk/thip/feed/domain/Content.java @@ -1,4 +1,4 @@ -package konkuk.thip.post.domain; +package konkuk.thip.feed.domain; import konkuk.thip.common.entity.BaseDomainEntity; import lombok.Getter; diff --git a/src/main/java/konkuk/thip/feed/domain/Feed.java b/src/main/java/konkuk/thip/feed/domain/Feed.java index 4e7af7b9b..3fe0be1e5 100644 --- a/src/main/java/konkuk/thip/feed/domain/Feed.java +++ b/src/main/java/konkuk/thip/feed/domain/Feed.java @@ -1,11 +1,15 @@ package konkuk.thip.feed.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.InvalidStateException; import lombok.Builder; import lombok.Getter; import lombok.experimental.SuperBuilder; import java.util.List; +import java.util.stream.Collectors; + +import static konkuk.thip.common.exception.code.ErrorCode.*; @Getter @SuperBuilder @@ -32,7 +36,11 @@ public class Feed extends BaseDomainEntity { private List tagList; - public static Feed withoutId(String content, Long creatorId, Boolean isPublic, Long targetBookId, List tagList) { + private List contentList; + + public static Feed withoutId(String content, Long creatorId, Boolean isPublic, Long targetBookId, + List tagValues, List imageUrls) { + return Feed.builder() .id(null) .content(content) @@ -42,8 +50,49 @@ public static Feed withoutId(String content, Long creatorId, Boolean isPublic, L .likeCount(0) .commentCount(0) .targetBookId(targetBookId) - .tagList(tagList) + .tagList(Tag.fromList(tagValues)) + .contentList(convertToContentList(imageUrls)) .build(); } + private static List convertToContentList(List imageUrls) { + if (imageUrls == null) return List.of(); + + 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()); + 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개까지 입력할 수 있습니다.")); + } + + // 태그 중복 체크 + if (!tagListEmpty) { + long distinctCount = tagList.stream().distinct().count(); + if (distinctCount != tagList.size()) { + throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("태그는 중복 될 수 없습니다.")); + } + } + } + + public static void validateImageCount(int imageSize) { + if (imageSize > 3) { + throw new InvalidStateException(INVALID_FEED_CREATE, new IllegalArgumentException("이미지는 최대 3개까지 업로드할 수 있습니다.")); + } + } + } diff --git a/src/main/java/konkuk/thip/feed/domain/Tag.java b/src/main/java/konkuk/thip/feed/domain/Tag.java index f84214409..cbdce5a86 100644 --- a/src/main/java/konkuk/thip/feed/domain/Tag.java +++ b/src/main/java/konkuk/thip/feed/domain/Tag.java @@ -1,17 +1,19 @@ package konkuk.thip.feed.domain; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.room.domain.Category; import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.ArrayList; +import java.util.List; + import static konkuk.thip.common.exception.code.ErrorCode.TAG_NAME_NOT_MATCH; @Getter @RequiredArgsConstructor public enum Tag { BOOK_RECOMMEND("책추천"), - TODAYS_BOOK("오늘의책"), + TODAY_BOOK("오늘의책"), READING_LOG("독서기록"), BOOK_REVIEW("책리뷰"), QUOTE("책속한줄"), @@ -36,4 +38,27 @@ public static Tag from(String value) { } throw new InvalidStateException(TAG_NAME_NOT_MATCH); } + + public static List fromList(List values) { + if (values == null || values.isEmpty()) return List.of(); + + List tags = new ArrayList<>(); + List invalidValues = new ArrayList<>(); + + for (String value : values) { + try { + tags.add(Tag.from(value)); + } catch (InvalidStateException e) { + invalidValues.add(value); + } + } + + if (!invalidValues.isEmpty()) { + String message = "다음 태그 이름이 유효하지 않습니다: " + String.join(", ", invalidValues); + throw new InvalidStateException(TAG_NAME_NOT_MATCH, new IllegalArgumentException(message)); + } + + return tags; + } + } 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 2ed339ac2..2d7194530 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,6 +9,7 @@ import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; 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; @@ -30,7 +31,6 @@ public Room findById(Long id) { RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(id).orElseThrow( () -> new EntityNotFoundException(ROOM_NOT_FOUND) ); - return roomMapper.toDomainEntity(roomJpaEntity); } @@ -47,4 +47,11 @@ public Long save(Room room) { RoomJpaEntity roomJpaEntity = roomMapper.toJpaEntity(room, bookJpaEntity, categoryJpaEntity); 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()); + } } 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 57cdafd88..dd0aa4716 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 @@ -1,5 +1,6 @@ package konkuk.thip.room.application.port.out; +import konkuk.thip.room.domain.Category; import konkuk.thip.room.domain.Room; public interface RoomCommandPort { @@ -7,4 +8,6 @@ public interface RoomCommandPort { Room findById(Long id); Long save(Room room); + + Category findCategoryByValue(String value); } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java index c10dcdc3f..26993b81a 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java @@ -16,7 +16,7 @@ import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; -import konkuk.thip.feed.adapter.out.persistence.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; import konkuk.thip.saved.adapter.out.persistence.repository.SavedBookJpaRepository; import org.junit.jupiter.api.*; @@ -37,9 +37,6 @@ @DisplayName("[통합] BookDetailSearchController 테스트") class BookDetailSearchControllerTest { - @Autowired - private MockMvc mockMvc; - @Autowired private BookSearchService bookSearchService; @Autowired @@ -59,10 +56,6 @@ class BookDetailSearchControllerTest { @Autowired private CategoryJpaRepository categoryJpaRepository; - @Autowired - private JwtUtil jwtUtil; - - @BeforeEach void setup() { AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); @@ -77,7 +70,7 @@ void setup() { BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() .title("작별하지 않는다") - .isbn("9788954682152") + .isbn("9791168342941") .authorName("한강") .bestSeller(false) .publisher("문학동네") @@ -137,7 +130,7 @@ void tearDown() { @Test @DisplayName("책 상세 검색 결과를 정상적으로 반환.") void searchDetailBooks_ReturnsCorrectResult() { - String isbn = "9788954682152"; + String isbn = "9791168342941"; UserJpaEntity user = userJpaRepository.findAll().get(0); var result = bookSearchService.searchDetailBooks(isbn, user.getUserId()); @@ -152,7 +145,7 @@ void searchDetailBooks_ReturnsCorrectResult() { @Test @DisplayName("모집 중인 방이 없으면 recruitingRoomCount가 0") void searchDetailBooks_NoRecruitingRooms_ReturnsZero() { - String isbn = "9788954682152"; + String isbn = "9791168342941"; UserJpaEntity user = userJpaRepository.findAll().get(0); BookJpaEntity book = bookJpaRepository.findAll().get(0); @@ -182,7 +175,7 @@ void searchDetailBooks_NoRecruitingRooms_ReturnsZero() { @Test @DisplayName("피드와 방 참여자가 모두 없으면 recruitingReadCount가 0") void searchDetailBooks_NoFeedOrRoomParticipants_ReturnsZero() { - String isbn = "9788954682152"; + String isbn = "9791168342941"; UserJpaEntity user = userJpaRepository.findAll().get(0); feedJpaRepository.deleteAll(); @@ -195,7 +188,7 @@ void searchDetailBooks_NoFeedOrRoomParticipants_ReturnsZero() { @Test @DisplayName("사용자가 책을 저장하지 않았으면 isSaved가 false") void searchDetailBooks_BookNotSaved_ReturnsFalse() { - String isbn = "9788954682152"; + String isbn = "9791168342941"; UserJpaEntity user = userJpaRepository.findAll().get(0); savedBookJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index a964f4da2..ee6c8c707 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -1,6 +1,7 @@ package konkuk.thip.common.util; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; @@ -74,6 +75,19 @@ public static BookJpaEntity createBook() { .build(); } + public static BookJpaEntity createBookWithISBN(String isbn) { + return BookJpaEntity.builder() + .title("책제목") + .authorName("저자") + .isbn(isbn) + .bestSeller(false) + .publisher("출판사") + .imageUrl("img") + .pageCount(100) + .description("설명") + .build(); + } + public static RoomJpaEntity createRoom(BookJpaEntity book, CategoryJpaEntity category) { return RoomJpaEntity.builder() .title("방이름") @@ -144,4 +158,11 @@ public static FollowingJpaEntity createFollowing(UserJpaEntity followerUser,User .followingUserJpaEntity(followingUser) .build(); } + + public static TagJpaEntity createTag(CategoryJpaEntity category,String value) { + return TagJpaEntity.builder() + .categoryJpaEntity(category) + .value(value) + .build(); + } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/config/TestS3MockConfig.java b/src/test/java/konkuk/thip/config/TestS3MockConfig.java new file mode 100644 index 000000000..a26596680 --- /dev/null +++ b/src/test/java/konkuk/thip/config/TestS3MockConfig.java @@ -0,0 +1,27 @@ +package konkuk.thip.config; + +import konkuk.thip.feed.adapter.out.s3.S3Service; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestS3MockConfig { + + @Bean + public S3Service s3Service() { + S3Service mockS3Service = Mockito.mock(S3Service.class); + + // 필요한 메서드 Mock + Mockito.when(mockS3Service.uploadUserImageAndGetUrl(Mockito.any())) + .thenAnswer(invocation -> { + // 실제로는 업로드된 URL을 반환해야 함 + // 가짜 URL 반환 + return "https://mock-s3-bucket/fake-image-url.jpg"; + }); + + Mockito.doNothing().when(mockS3Service).deleteImageFromS3(Mockito.anyString()); + + return mockS3Service; + } +} diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateAPITest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateAPITest.java new file mode 100644 index 000000000..c17629db0 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateAPITest.java @@ -0,0 +1,396 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +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.FeedJpaEntity; +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.alias.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import org.junit.jupiter.api.AfterEach; +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.mock.web.MockMultipartFile; +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 static org.assertj.core.api.Assertions.assertThat; +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; + + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@Import(TestS3MockConfig.class) +@DisplayName("[통합] 피드 생성 api 통합 테스트") +class FeedCreateAPITest { + + @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; + + private AliasJpaEntity alias; + private UserJpaEntity user; + private CategoryJpaEntity category; + + @BeforeEach + void setUp() { + alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + tagJpaRepository.save(TestEntityFactory.createTag(category,"소설추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category,"책추천")); + tagJpaRepository.save(TestEntityFactory.createTag(category,"오늘의책")); + + } + + @AfterEach + void tearDown() { + feedTagJpaRepository.deleteAll(); + feedJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + tagJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + + @Test + @DisplayName("isbn 에 해당하는 책이 DB에 존재할 때, 해당 책과 연관된 피드를 생성할 수 있다.") + void createFeedWithBookExistsInDB() throws Exception { + + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); // 책 ISBN + request.put("contentBody", "이 책 정말 좋아요."); + request.put("isPublic", true); + request.put("category", "문학"); //실제 카테고리 값 + request.put("tagList", List.of("소설추천", "책추천", "오늘의책")); //실제 태그 값 + + MockMultipartFile requestPart = new MockMultipartFile( + "request", // requestPart name + "", // 빈 파일명 + MediaType.APPLICATION_JSON_VALUE, // Content type + objectMapper.writeValueAsBytes(request) // 우릴 JSON 바이트로 + ); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .requestAttr("userId", user.getUserId())); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + // DB에 피드가 저장되었는지 확인 + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(postId).orElse(null); + + assertThat(feedJpaEntity).isNotNull(); + assertThat(feedJpaEntity.getBookJpaEntity().getIsbn()).isEqualTo("9788954682152"); + assertThat(feedJpaEntity.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId()); + assertThat(feedJpaEntity.getIsPublic()).isTrue(); + assertThat(feedJpaEntity.getPostId()).isEqualTo(postId); + } + + @Test + @DisplayName("isbn 에 해당하는 책이 DB에 존재하지 않을 경우, 외부 api를 통해 책을 DB에 저장한 후 연관된 피드를 생성할 수 있다.") + void createFeedWithBookNotExists_usesExternalAPI() throws Exception { + + // given + String isbn = "9791168342941"; // 외부 API에서 정상 조회되는 실제 ISBN (DB에는 저장되어 있지 않음) + Map request = new HashMap<>(); + request.put("isbn", isbn); + request.put("contentBody", "외부 API를 통해 등록된 책 피드입니다."); + request.put("isPublic", true); + request.put("category", "문학"); //실제 카테고리 값 + request.put("tagList", List.of("소설추천", "책추천", "오늘의책")); //실제 태그 값 + + MockMultipartFile requestPart = new MockMultipartFile( + "request", // requestPart name + "", // 빈 파일명 + MediaType.APPLICATION_JSON_VALUE, // Content type + objectMapper.writeValueAsBytes(request) // 우릴 JSON 바이트로 + ); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .requestAttr("userId", user.getUserId())); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + // DB에 피드가 저장되었는지 확인 + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(postId).orElse(null); + assertThat(feedJpaEntity).isNotNull(); + assertThat(feedJpaEntity.getBookJpaEntity().getIsbn()).isEqualTo(isbn); + assertThat(feedJpaEntity.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId()); + assertThat(feedJpaEntity.getIsPublic()).isTrue(); + assertThat(feedJpaEntity.getPostId()).isEqualTo(postId); + + // 책이 실제로 DB에 저장되었는지 확인 + BookJpaEntity savedBook = bookJpaRepository.findAll().stream() + .filter(book -> isbn.equals(book.getIsbn())) + .findFirst() + .orElse(null); + + assertThat(savedBook).isNotNull(); + assertThat(savedBook.getIsbn()).isEqualTo(isbn); + } + + @Test + @DisplayName("피드 생성시, 이미지 파일이 여러 개 들어올 경우, 피드에 대응되는 Content가 각각 생성된 후 관된 피드를 생성할 수 있다.") + void createFeedWithImages_createsContentEntities() throws Exception { + + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); // 책 ISBN + request.put("contentBody", "이미지 테스트 피드"); + request.put("isPublic", true); + request.put("category", "문학"); //실제 카테고리 값 + request.put("tagList", List.of("소설추천")); // 실제 태그 값 + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + MockMultipartFile image1 = new MockMultipartFile("images", "img1.png", "image/png", "data1".getBytes()); + MockMultipartFile image2 = new MockMultipartFile("images", "img2.jpg", "image/jpeg", "data2".getBytes()); + MockMultipartFile image3 = new MockMultipartFile("images", "img3.jpeg", "image/jpeg", "data3".getBytes()); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .file(image1) + .file(image2) + .file(image3) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + // DB에 피드가 저장되었는지 확인 + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(postId).orElse(null); + assertThat(feedJpaEntity).isNotNull(); + assertThat(feedJpaEntity.getBookJpaEntity().getIsbn()).isEqualTo("9788954682152"); + assertThat(feedJpaEntity.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId()); + assertThat(feedJpaEntity.getIsPublic()).isTrue(); + assertThat(feedJpaEntity.getPostId()).isEqualTo(postId); + + // Content 검증 + assertThat(feedJpaEntity.getContentList()).hasSize(3); + assertThat(feedJpaEntity.getContentList()) + .extracting("contentUrl") + .containsExactlyInAnyOrder( + "https://mock-s3-bucket/fake-image-url.jpg", + "https://mock-s3-bucket/fake-image-url.jpg", + "https://mock-s3-bucket/fake-image-url.jpg" + ); + + } + + @Test + @DisplayName("이미지가 없는 피드를 생성하면 Feed의 contentList는 비어 있어야 한다.") + void createFeedWithoutImages_shouldHaveEmptyContentList() throws Exception { + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("contentBody", "이미지 없는 피드"); + request.put("isPublic", true); + request.put("category", "문학"); + request.put("tagList", List.of("소설추천")); // 태그 있는 상황 + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + FeedJpaEntity feed = feedJpaRepository.findById(postId).orElseThrow(); + assertThat(feed.getContentList()).isEmpty(); + } + + + @Test + @DisplayName("피드 생성시, 태그가 들어오면 feed_tags 매핑 테이블에 정상적으로 3개의 태그가 저장된된 후 관련 피드를 생성할 수 있다.") + void createFeedWithTags_createsFeedTagMappings() throws Exception { + + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("contentBody", "태그 매핑 테스트 중입니다."); + request.put("isPublic", true); + request.put("category", "문학"); + List tags = List.of("소설추천", "책추천", "오늘의책"); + request.put("tagList", tags); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + + // DB에 피드가 저장되었는지 확인 + FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(postId).orElse(null); + assertThat(feedJpaEntity).isNotNull(); + assertThat(feedJpaEntity.getBookJpaEntity().getIsbn()).isEqualTo("9788954682152"); + assertThat(feedJpaEntity.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId()); + assertThat(feedJpaEntity.getIsPublic()).isTrue(); + assertThat(feedJpaEntity.getPostId()).isEqualTo(postId); + + // DB에 feed_tags 저장되었는지 확인 + long mappingCount = feedTagJpaRepository.findAll().stream() + .filter(f -> f.getFeedJpaEntity().getPostId().equals(postId)) + .count(); + assertThat(mappingCount).isEqualTo(3); + } + + @Test + @DisplayName("카테고리와 태그가 없는 피드는 feed_tags 매핑이 없어야 한다.") + void createFeedWithoutTags_shouldNotHaveFeedTags() throws Exception { + // given + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("contentBody", "태그 없는 피드"); + request.put("isPublic", true); + request.put("category", ""); // 카테고리 없이 + request.put("tagList", List.of()); // 태그 없음 + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + // when + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + Long postId = root.path("data").path("feedId").asLong(); + + long feedTagCount = feedTagJpaRepository.findAll().stream() + .filter(f -> f.getFeedJpaEntity().getPostId().equals(postId)) + .count(); + + assertThat(feedTagCount).isEqualTo(0); // feed_tags 매핑 없음 + } + + +} 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 new file mode 100644 index 000000000..33ed9fa47 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java @@ -0,0 +1,180 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.HashMap; +import java.util.List; +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 org.hamcrest.Matchers.containsString; +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; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 피드 생성 api controller 단위 테스트") +class FeedCreateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + + private Map buildValidRequest() { + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("contentBody", "테스트 콘텐츠"); + request.put("isPublic", true); + request.put("category", "문학"); + request.put("tagList", List.of("책추천", "소설추천")); + return request; + } + + private void assertBadRequest_InvalidFeedCreate(Map request, String message) throws Exception { + mockMvc.perform(multipart("/feeds") + .file(new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request))) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .requestAttr("userId", 1L)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(INVALID_FEED_CREATE.getCode())) + .andExpect(jsonPath("$.message", containsString(message))); + } + + private void assertBadRequest_InvalidParam(Map request, String message) throws Exception { + mockMvc.perform(multipart("/feeds") + .file(new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request))) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .requestAttr("userId", 1L)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString(message))); + } + + @Nested + @DisplayName("기본 필드 검증") + class BasicValidation { + + @Test + @DisplayName("ISBN이 빈 문자열이면 400 반환") + void blankIsbn() throws Exception { + Map req = buildValidRequest(); + req.put("isbn", ""); + assertBadRequest_InvalidParam(req, "ISBN은 필수입니다."); + } + + @Test + @DisplayName("콘텐츠 내용이 없으면 400 반환") + void blankContent() throws Exception { + Map req = buildValidRequest(); + req.put("contentBody", ""); + assertBadRequest_InvalidParam(req, "콘텐츠 내용은 필수입니다."); + } + + @Test + @DisplayName("값이 없을 때 400 error") + void missing_is_public() throws Exception { + Map req = buildValidRequest(); + req.put("isPublic", null); + assertBadRequest_InvalidParam(req, "방 공개 설정 여부는 필수입니다."); + } + + } + + @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, "카테고리와 태그는 모두 입력되거나 모두 비워져야 합니다."); + } + + @Test + @DisplayName("태그가 6개 이상이면 400 반환") + void tooManyTags() throws Exception { + Map req = buildValidRequest(); + req.put("tagList", List.of("1", "2", "3", "4", "5", "6")); + assertBadRequest_InvalidFeedCreate(req, "태그는 최대 5개까지 입력할 수 있습니다."); + } + + @Test + @DisplayName("태그가 중복되면 400 반환") + void duplicatedTags() throws Exception { + Map req = buildValidRequest(); + req.put("tagList", List.of("중복", "중복")); + assertBadRequest_InvalidFeedCreate(req, "태그는 중복 될 수 없습니다."); + } + } + + @Nested + @DisplayName("이미지 개수 검증") + class ImageValidation { + + @Test + @DisplayName("이미지가 3개 초과되면 400 반환") + void tooManyImages() throws Exception { + Map req = buildValidRequest(); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(req) + ); + + // 이미지 4개 세팅 + List images = List.of( + new MockMultipartFile("images", "img1.jpg", MediaType.IMAGE_JPEG_VALUE, "1".getBytes()), + new MockMultipartFile("images", "img2.jpg", MediaType.IMAGE_JPEG_VALUE, "2".getBytes()), + new MockMultipartFile("images", "img3.jpg", MediaType.IMAGE_JPEG_VALUE, "3".getBytes()), + new MockMultipartFile("images", "img4.jpg", MediaType.IMAGE_JPEG_VALUE, "4".getBytes()) + ); + + + ResultActions result = mockMvc.perform(multipart("/feeds") + .file(requestPart) + .file(images.get(0)) + .file(images.get(1)) + .file(images.get(2)) + .file(images.get(3)) + .requestAttr("userId", 1L) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ); + + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(INVALID_FEED_CREATE.getCode())) + .andExpect(jsonPath("$.message",containsString("이미지는 최대 3개까지 업로드할 수 있습니다."))); + + } + } + +} diff --git a/src/test/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntityTest.java b/src/test/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntityTest.java index 6f1d86a01..c774975b9 100644 --- a/src/test/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntityTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntityTest.java @@ -4,7 +4,7 @@ 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.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java index 5b91b716a..006b9418b 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java @@ -89,12 +89,12 @@ private void saveUserAndCategory() { private void saveBookWithPageCount() { bookJpaRepository.save(BookJpaEntity.builder() .title("작별하지 않는다") - .isbn("9788954682152") // 실제 isbn 값 - .authorName("한강") + .isbn("9791168342941") // 실제 isbn 값 + .authorName("박곰희") .bestSeller(false) .publisher("문학동네") .imageUrl("https://image1.jpg") - .pageCount(332) // pageCount 값이 null이 아닌 책 + .pageCount(296) // pageCount 값이 null이 아닌 책 .description("한강의 소설") .build()); } @@ -102,19 +102,19 @@ private void saveBookWithPageCount() { private void saveBookWithoutPageCount() { bookJpaRepository.save(BookJpaEntity.builder() .title("작별하지 않는다") - .isbn("9788954682152") // 실제 isbn 값 - .authorName("한강") + .isbn("9791168342941") // 실제 isbn 값 + .authorName("박곰희") .bestSeller(false) .publisher("문학동네") .imageUrl("https://image1.jpg") - .pageCount(null) // pageCount 값이 null 인 책 -> 실제 페이지 정보 332 + .pageCount(null) // pageCount 값이 null 인 책 -> 실제 페이지 정보 296 .description("한강의 소설") .build()); } private Map buildRoomCreateRequest() { Map request = new HashMap<>(); - request.put("isbn", "9788954682152"); + request.put("isbn", "9791168342941"); request.put("category", "문학"); // 실제 카테고리 값 request.put("roomName", "방이름"); request.put("description", "방설명"); @@ -217,7 +217,7 @@ void room_create_book_without_page_exist() throws Exception { // update 된 책 검증 BookJpaEntity updatedBookJpaEntity = bookJpaRepository.findById(bookId).orElse(null); - assertThat(updatedBookJpaEntity.getPageCount()).isEqualTo(332); + assertThat(updatedBookJpaEntity.getPageCount()).isEqualTo(296); } @Test @@ -269,7 +269,7 @@ void room_create_book_not_exist() throws Exception { "isbn", "authorName", "pageCount" ) .containsExactly( - "9788954682152", "한강", 332 + "9791168342941", "박곰희", 296 ); }