diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index dabaf0779..5dee87723 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -27,6 +27,11 @@ jobs: JWT_SECRET: ${{ secrets.JWT_SECRET }} FRONT_URL: ${{secrets.FRONT_URL}} BACK_URL: ${{secrets.BACK_URL}} + BUCKET_NAME: ${{secrets.BUCKET_NAME}} + BUCKET_REGION: ${{secrets.BUCKET_REGION}} + IMG_BASE_URL: ${{secrets.BASE_URL}} + S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} + S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} steps: - name: Github Repository 파일 불러오기 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 26939d2b9..629520d89 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,6 +35,11 @@ jobs: JWT_SECRET: ${{ secrets.JWT_SECRET }} FRONT_URL: ${{secrets.FRONT_URL}} BACK_URL: ${{secrets.BACK_URL}} + BUCKET_NAME: ${{secrets.BUCKET_NAME}} + BUCKET_REGION: ${{secrets.BUCKET_REGION}} + IMG_BASE_URL: ${{secrets.BASE_URL}} + S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} + S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} steps: diff --git a/build.gradle b/build.gradle index c49448165..ebb137d36 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // AWS + implementation(platform("software.amazon.awssdk:bom:2.29.20")) + implementation("software.amazon.awssdk:s3") // Web Layer implementation 'org.springframework.boot:spring-boot-starter-web' @@ -61,7 +64,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0' - //test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/somemore/community/domain/CommunityComment.java b/src/main/java/com/somemore/community/domain/CommunityComment.java new file mode 100644 index 000000000..8e92e1884 --- /dev/null +++ b/src/main/java/com/somemore/community/domain/CommunityComment.java @@ -0,0 +1,41 @@ +package com.somemore.community.domain; + +import com.somemore.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "community_comment") +public class CommunityComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "writer_id", nullable = false, length = 16) + private UUID writerId; + + @Lob + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "parent_comment_id") + private Long parentCommentId; + + @Builder + public CommunityComment(UUID writerId, String content, Long parentCommentId) { + this.writerId = writerId; + this.content = content; + this.parentCommentId = parentCommentId; + } +} \ No newline at end of file diff --git a/src/main/java/com/somemore/community/dto/request/CommunityCommentCreateRequestDto.java b/src/main/java/com/somemore/community/dto/request/CommunityCommentCreateRequestDto.java new file mode 100644 index 000000000..4366779a0 --- /dev/null +++ b/src/main/java/com/somemore/community/dto/request/CommunityCommentCreateRequestDto.java @@ -0,0 +1,30 @@ +package com.somemore.community.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.community.domain.CommunityComment; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Builder +public record CommunityCommentCreateRequestDto( + @Schema(description = "커뮤니티 댓글 내용", example = "저도 함께 하고 싶습니다.") + @NotBlank(message = "댓글 내용은 필수 값입니다.") + String content, + @Schema(description = "부모 댓글의 ID", example = "1234", nullable = true) + @Nullable + Long parentCommentId +) { + public CommunityComment toEntity(UUID writerId) { + return CommunityComment.builder() + .writerId(writerId) + .content(content) + .parentCommentId(parentCommentId) + .build(); + } +} diff --git a/src/main/java/com/somemore/community/repository/CommunityBoardJpaRepository.java b/src/main/java/com/somemore/community/repository/board/CommunityBoardJpaRepository.java similarity index 81% rename from src/main/java/com/somemore/community/repository/CommunityBoardJpaRepository.java rename to src/main/java/com/somemore/community/repository/board/CommunityBoardJpaRepository.java index ce59211b9..79b03cd1c 100644 --- a/src/main/java/com/somemore/community/repository/CommunityBoardJpaRepository.java +++ b/src/main/java/com/somemore/community/repository/board/CommunityBoardJpaRepository.java @@ -1,4 +1,4 @@ -package com.somemore.community.repository; +package com.somemore.community.repository.board; import com.somemore.community.domain.CommunityBoard; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/somemore/community/repository/CommunityBoardRepository.java b/src/main/java/com/somemore/community/repository/board/CommunityBoardRepository.java similarity index 90% rename from src/main/java/com/somemore/community/repository/CommunityBoardRepository.java rename to src/main/java/com/somemore/community/repository/board/CommunityBoardRepository.java index f7df7bbd5..3340735a5 100644 --- a/src/main/java/com/somemore/community/repository/CommunityBoardRepository.java +++ b/src/main/java/com/somemore/community/repository/board/CommunityBoardRepository.java @@ -1,4 +1,4 @@ -package com.somemore.community.repository; +package com.somemore.community.repository.board; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.domain.CommunityBoardView; diff --git a/src/main/java/com/somemore/community/repository/CommunityRepositoryImpl.java b/src/main/java/com/somemore/community/repository/board/CommunityBoardRepositoryImpl.java similarity index 94% rename from src/main/java/com/somemore/community/repository/CommunityRepositoryImpl.java rename to src/main/java/com/somemore/community/repository/board/CommunityBoardRepositoryImpl.java index 26ec5360e..ad1d79924 100644 --- a/src/main/java/com/somemore/community/repository/CommunityRepositoryImpl.java +++ b/src/main/java/com/somemore/community/repository/board/CommunityBoardRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.somemore.community.repository; +package com.somemore.community.repository.board; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQuery; @@ -16,7 +16,7 @@ @RequiredArgsConstructor @Repository -public class CommunityRepositoryImpl implements CommunityBoardRepository { +public class CommunityBoardRepositoryImpl implements CommunityBoardRepository { private final JPAQueryFactory queryFactory; private final CommunityBoardJpaRepository communityBoardJpaRepository; diff --git a/src/main/java/com/somemore/community/repository/comment/CommunityCommentJpaRepository.java b/src/main/java/com/somemore/community/repository/comment/CommunityCommentJpaRepository.java new file mode 100644 index 000000000..fd02cad53 --- /dev/null +++ b/src/main/java/com/somemore/community/repository/comment/CommunityCommentJpaRepository.java @@ -0,0 +1,7 @@ +package com.somemore.community.repository.comment; + +import com.somemore.community.domain.CommunityComment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommunityCommentJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepository.java b/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepository.java new file mode 100644 index 000000000..07722e607 --- /dev/null +++ b/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepository.java @@ -0,0 +1,12 @@ +package com.somemore.community.repository.comment; + +import com.somemore.community.domain.CommunityComment; + +import java.util.Optional; + +public interface CommunityCommentRepository { + CommunityComment save(CommunityComment communityComment); + Optional findById(Long id); + boolean existsById(Long id); + void deleteAllInBatch(); +} diff --git a/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepositoryImpl.java b/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepositoryImpl.java new file mode 100644 index 000000000..8a6199f65 --- /dev/null +++ b/src/main/java/com/somemore/community/repository/comment/CommunityCommentRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.somemore.community.repository.comment; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.somemore.community.domain.CommunityComment; +import com.somemore.community.domain.QCommunityComment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CommunityCommentRepositoryImpl implements CommunityCommentRepository { + + private final JPAQueryFactory queryFactory; + private final CommunityCommentJpaRepository communityCommentJpaRepository; + + @Override + public CommunityComment save(CommunityComment communityComment) { + return communityCommentJpaRepository.save(communityComment); + } + + @Override + public Optional findById(Long id) { + QCommunityComment communityComment = QCommunityComment.communityComment; + + return Optional.ofNullable(queryFactory + .selectFrom(communityComment) + .where(communityComment.id.eq(id) + .and(communityComment.deleted.eq(false))) + .fetchOne()); + } + + @Override + public boolean existsById(Long id) { + QCommunityComment communityComment = QCommunityComment.communityComment; + + return queryFactory + .selectOne() + .from(communityComment) + .where(communityComment.id.eq(id) + .and(communityComment.deleted.eq(false))) + .fetchFirst() != null; + } + + @Override + public void deleteAllInBatch() { communityCommentJpaRepository.deleteAllInBatch(); } +} diff --git a/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java b/src/main/java/com/somemore/community/service/board/CommunityBoardQueryService.java similarity index 91% rename from src/main/java/com/somemore/community/service/CommunityBoardQueryService.java rename to src/main/java/com/somemore/community/service/board/CommunityBoardQueryService.java index e451949ff..98d45143f 100644 --- a/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java +++ b/src/main/java/com/somemore/community/service/board/CommunityBoardQueryService.java @@ -1,11 +1,11 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.domain.CommunityBoardView; import com.somemore.community.dto.response.CommunityBoardGetDetailResponseDto; import com.somemore.community.dto.response.CommunityBoardGetResponseDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.CommunityBoardQueryUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.CommunityBoardQueryUseCase; import com.somemore.global.exception.BadRequestException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/somemore/community/service/CreateCommunityBoardService.java b/src/main/java/com/somemore/community/service/board/CreateCommunityBoardService.java similarity index 81% rename from src/main/java/com/somemore/community/service/CreateCommunityBoardService.java rename to src/main/java/com/somemore/community/service/board/CreateCommunityBoardService.java index 07ad1d456..61bcf1767 100644 --- a/src/main/java/com/somemore/community/service/CreateCommunityBoardService.java +++ b/src/main/java/com/somemore/community/service/board/CreateCommunityBoardService.java @@ -1,9 +1,9 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.CreateCommunityBoardUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.CreateCommunityBoardUseCase; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/somemore/community/service/DeleteCommunityBoardService.java b/src/main/java/com/somemore/community/service/board/DeleteCommunityBoardService.java similarity index 88% rename from src/main/java/com/somemore/community/service/DeleteCommunityBoardService.java rename to src/main/java/com/somemore/community/service/board/DeleteCommunityBoardService.java index 79c735bf9..ed9ff1424 100644 --- a/src/main/java/com/somemore/community/service/DeleteCommunityBoardService.java +++ b/src/main/java/com/somemore/community/service/board/DeleteCommunityBoardService.java @@ -1,8 +1,8 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import com.somemore.community.domain.CommunityBoard; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.DeleteCommunityBoardUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.DeleteCommunityBoardUseCase; import com.somemore.global.exception.BadRequestException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/somemore/community/service/UpdateCommunityBoardService.java b/src/main/java/com/somemore/community/service/board/UpdateCommunityBoardService.java similarity index 88% rename from src/main/java/com/somemore/community/service/UpdateCommunityBoardService.java rename to src/main/java/com/somemore/community/service/board/UpdateCommunityBoardService.java index ef3f96c36..89a5dfb84 100644 --- a/src/main/java/com/somemore/community/service/UpdateCommunityBoardService.java +++ b/src/main/java/com/somemore/community/service/board/UpdateCommunityBoardService.java @@ -1,9 +1,9 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.dto.request.CommunityBoardUpdateRequestDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.UpdateCommunityBoardUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.UpdateCommunityBoardUseCase; import com.somemore.global.exception.BadRequestException; import java.util.UUID; diff --git a/src/main/java/com/somemore/community/service/comment/CreateCommunityCommentService.java b/src/main/java/com/somemore/community/service/comment/CreateCommunityCommentService.java new file mode 100644 index 000000000..e6da18066 --- /dev/null +++ b/src/main/java/com/somemore/community/service/comment/CreateCommunityCommentService.java @@ -0,0 +1,37 @@ +package com.somemore.community.service.comment; + +import com.somemore.community.domain.CommunityComment; +import com.somemore.community.dto.request.CommunityCommentCreateRequestDto; +import com.somemore.community.repository.comment.CommunityCommentRepository; +import com.somemore.community.usecase.comment.CreateCommunityCommentUseCase; +import com.somemore.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_COMMUNITY_COMMENT; + +@RequiredArgsConstructor +@Transactional +@Service +public class CreateCommunityCommentService implements CreateCommunityCommentUseCase { + + private final CommunityCommentRepository communityCommentRepository; + + @Override + public Long createCommunityComment(CommunityCommentCreateRequestDto requestDto, UUID writerId) { + CommunityComment communityComment = requestDto.toEntity(writerId); + + validateParentCommentExists(communityComment.getParentCommentId()); + + return communityCommentRepository.save(communityComment).getId(); + } + + private void validateParentCommentExists(Long parentCommentId) { + if (parentCommentId != null && !communityCommentRepository.existsById(parentCommentId)) { + throw new BadRequestException(NOT_EXISTS_COMMUNITY_COMMENT.getMessage()); + } + } +} diff --git a/src/main/java/com/somemore/community/usecase/CommunityBoardQueryUseCase.java b/src/main/java/com/somemore/community/usecase/board/CommunityBoardQueryUseCase.java similarity index 91% rename from src/main/java/com/somemore/community/usecase/CommunityBoardQueryUseCase.java rename to src/main/java/com/somemore/community/usecase/board/CommunityBoardQueryUseCase.java index 2b4da6312..70987a033 100644 --- a/src/main/java/com/somemore/community/usecase/CommunityBoardQueryUseCase.java +++ b/src/main/java/com/somemore/community/usecase/board/CommunityBoardQueryUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.community.usecase; +package com.somemore.community.usecase.board; import com.somemore.community.dto.response.CommunityBoardGetDetailResponseDto; import com.somemore.community.dto.response.CommunityBoardGetResponseDto; diff --git a/src/main/java/com/somemore/community/usecase/CreateCommunityBoardUseCase.java b/src/main/java/com/somemore/community/usecase/board/CreateCommunityBoardUseCase.java similarity index 85% rename from src/main/java/com/somemore/community/usecase/CreateCommunityBoardUseCase.java rename to src/main/java/com/somemore/community/usecase/board/CreateCommunityBoardUseCase.java index 2e0c3828f..6df8878da 100644 --- a/src/main/java/com/somemore/community/usecase/CreateCommunityBoardUseCase.java +++ b/src/main/java/com/somemore/community/usecase/board/CreateCommunityBoardUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.community.usecase; +package com.somemore.community.usecase.board; import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; diff --git a/src/main/java/com/somemore/community/usecase/DeleteCommunityBoardUseCase.java b/src/main/java/com/somemore/community/usecase/board/DeleteCommunityBoardUseCase.java similarity index 73% rename from src/main/java/com/somemore/community/usecase/DeleteCommunityBoardUseCase.java rename to src/main/java/com/somemore/community/usecase/board/DeleteCommunityBoardUseCase.java index d2f26500e..345936db9 100644 --- a/src/main/java/com/somemore/community/usecase/DeleteCommunityBoardUseCase.java +++ b/src/main/java/com/somemore/community/usecase/board/DeleteCommunityBoardUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.community.usecase; +package com.somemore.community.usecase.board; import java.util.UUID; diff --git a/src/main/java/com/somemore/community/usecase/UpdateCommunityBoardUseCase.java b/src/main/java/com/somemore/community/usecase/board/UpdateCommunityBoardUseCase.java similarity index 87% rename from src/main/java/com/somemore/community/usecase/UpdateCommunityBoardUseCase.java rename to src/main/java/com/somemore/community/usecase/board/UpdateCommunityBoardUseCase.java index e8690b283..4014f5fe3 100644 --- a/src/main/java/com/somemore/community/usecase/UpdateCommunityBoardUseCase.java +++ b/src/main/java/com/somemore/community/usecase/board/UpdateCommunityBoardUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.community.usecase; +package com.somemore.community.usecase.board; import com.somemore.community.dto.request.CommunityBoardUpdateRequestDto; diff --git a/src/main/java/com/somemore/community/usecase/comment/CreateCommunityCommentUseCase.java b/src/main/java/com/somemore/community/usecase/comment/CreateCommunityCommentUseCase.java new file mode 100644 index 000000000..3def6667f --- /dev/null +++ b/src/main/java/com/somemore/community/usecase/comment/CreateCommunityCommentUseCase.java @@ -0,0 +1,11 @@ +package com.somemore.community.usecase.comment; + +import com.somemore.community.dto.request.CommunityCommentCreateRequestDto; + +import java.util.UUID; + +public interface CreateCommunityCommentUseCase { + Long createCommunityComment( + CommunityCommentCreateRequestDto requestDto, + UUID writerId); +} diff --git a/src/main/java/com/somemore/domains/CommunityComment.java b/src/main/java/com/somemore/domains/CommunityComment.java deleted file mode 100644 index 423975fbd..000000000 --- a/src/main/java/com/somemore/domains/CommunityComment.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.somemore.domains; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; - -import java.util.UUID; - -@Getter -@Setter -@Entity -@Table(name = "Community_comment") -public class CommunityComment { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - - @Column(name = "writer_id", nullable = false, length = 16) - private String writerId; - - @Lob - @Column(name = "content", nullable = false) - private String content; - - @Column(name = "parent_comment_id") - private Long parentCommentId; - -} \ No newline at end of file diff --git a/src/main/java/com/somemore/global/configure/S3Config.java b/src/main/java/com/somemore/global/configure/S3Config.java new file mode 100644 index 000000000..b58ef6755 --- /dev/null +++ b/src/main/java/com/somemore/global/configure/S3Config.java @@ -0,0 +1,45 @@ +package com.somemore.global.configure; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@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 AwsCredentials basicAWSCredentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } + + @Bean + public S3Presigner s3Presigner(AwsCredentials awsCredentials) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } + + @Bean + public S3Client s3Client(AwsCredentials awsCredentials) { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } + +} diff --git a/src/main/java/com/somemore/global/exception/ExceptionMessage.java b/src/main/java/com/somemore/global/exception/ExceptionMessage.java index 2c3b040c7..1e5b3f8b2 100644 --- a/src/main/java/com/somemore/global/exception/ExceptionMessage.java +++ b/src/main/java/com/somemore/global/exception/ExceptionMessage.java @@ -11,9 +11,15 @@ public enum ExceptionMessage { NOT_EXISTS_CENTER("존재하지 않는 기관 ID 입니다."), NOT_EXISTS_COMMUNITY_BOARD("존재하지 않는 게시글 입니다."), UNAUTHORIZED_COMMUNITY_BOARD("해당 게시글에 권한이 없습니다."), + NOT_EXISTS_COMMUNITY_COMMENT("존재하지 않는 댓글 입니다."), NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."), NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."), UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."), + UPLOAD_FAILED("파일 업로드에 실패했습니다."), + INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."), + EMPTY_FILE("파일이 존재하지 않습니다."), + INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다.") ; private final String message; diff --git a/src/main/java/com/somemore/global/exception/ImageUploadException.java b/src/main/java/com/somemore/global/exception/ImageUploadException.java new file mode 100644 index 000000000..d314407ea --- /dev/null +++ b/src/main/java/com/somemore/global/exception/ImageUploadException.java @@ -0,0 +1,8 @@ +package com.somemore.global.exception; + +public class ImageUploadException extends RuntimeException{ + + public ImageUploadException(String message) { + super(message); + } +} diff --git a/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java b/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java index 742719d0d..3f6cc0736 100644 --- a/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java @@ -2,10 +2,13 @@ import com.somemore.global.exception.BadRequestException; +import com.somemore.global.exception.ImageUploadException; +import org.springframework.data.crossstore.ChangeSetPersister; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @@ -25,4 +28,15 @@ ProblemDetail handleBadRequestException(final BadRequestException e) { return problemDetail; } + @ExceptionHandler(ImageUploadException.class) + ProblemDetail handleImageUploadException(final ImageUploadException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); + + problemDetail.setTitle("이미지 업로드 실패"); + problemDetail.setDetail("업로드 중 문제가 발생했습니다. 파일 크기나 형식이 올바른지 확인해 주세요."); + + return problemDetail; + } + } diff --git a/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java b/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java new file mode 100644 index 000000000..8cc01de84 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java @@ -0,0 +1,8 @@ +package com.somemore.imageupload.dto; + +import org.springframework.web.multipart.MultipartFile; + +public record ImageUploadRequestDto( + MultipartFile imageFile +) { +} diff --git a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java new file mode 100644 index 000000000..1542ac30a --- /dev/null +++ b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java @@ -0,0 +1,63 @@ +package com.somemore.imageupload.service; + +import com.somemore.global.exception.ImageUploadException; +import com.somemore.imageupload.dto.ImageUploadRequestDto; +import com.somemore.imageupload.usecase.ImageUploadUseCase; +import com.somemore.imageupload.util.ImageUploadUtils; +import com.somemore.imageupload.validator.ImageUploadValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; + +import static com.somemore.global.exception.ExceptionMessage.UPLOAD_FAILED; + +@RequiredArgsConstructor +@Service +public class ImageUploadService implements ImageUploadUseCase { + + private final S3Client s3Client; + private final ImageUploadValidator imageUploadValidator; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.base-url}") + private String baseUrl; + + @Override + public String uploadImage(ImageUploadRequestDto requestDto) { + imageUploadValidator.validateFileSize(requestDto.imageFile()); + imageUploadValidator.validateFileType(requestDto.imageFile()); + + try { + return uploadToS3(requestDto.imageFile()); + } catch (IOException e) { + throw new ImageUploadException(UPLOAD_FAILED.getMessage()); + } + } + + private String uploadToS3(MultipartFile file) throws IOException { + String fileName = ImageUploadUtils.generateUniqueFileName(file.getOriginalFilename()); + + PutObjectRequest request = createPutObjectRequest(file, fileName); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return ImageUploadUtils.generateS3Url(baseUrl, fileName); + } + + private PutObjectRequest createPutObjectRequest(MultipartFile file, String fileName) { + return PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(file.getContentType()) + .build(); + } + +} diff --git a/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java b/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java new file mode 100644 index 000000000..acfd3ed2c --- /dev/null +++ b/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java @@ -0,0 +1,7 @@ +package com.somemore.imageupload.usecase; + +import com.somemore.imageupload.dto.ImageUploadRequestDto; + +public interface ImageUploadUseCase { + String uploadImage(ImageUploadRequestDto requestDto); +} diff --git a/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java new file mode 100644 index 000000000..c72e2c5b0 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java @@ -0,0 +1,27 @@ +package com.somemore.imageupload.util; + +import java.util.UUID; + +import static com.somemore.global.exception.ExceptionMessage.INSTANTIATION_NOT_ALLOWED; + +public class ImageUploadUtils { + + private ImageUploadUtils() { + throw new UnsupportedOperationException(INSTANTIATION_NOT_ALLOWED.getMessage()); + } + + public static String generateUniqueFileName(String originalFileName) { + String uuid = UUID.randomUUID().toString(); + String fileExtension = extractFileExtension(originalFileName); + return uuid + fileExtension; + } + + private static String extractFileExtension(String fileName) { + return fileName.substring(fileName.lastIndexOf(".")); + } + + public static String generateS3Url(String baseUrl, String fileName) { + return String.format("%s/%s", baseUrl, fileName); + } + +} diff --git a/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java b/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java new file mode 100644 index 000000000..865dd2844 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java @@ -0,0 +1,39 @@ +package com.somemore.imageupload.validator; + +import com.somemore.global.exception.ImageUploadException; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import static com.somemore.global.exception.ExceptionMessage.*; + +@Component +public class DefaultImageUploadValidator implements ImageUploadValidator { + + private static final long MAX_FILE_SIZE = 8L * 1024 * 1024; // 8MB + + public void validateFileSize(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageUploadException(EMPTY_FILE.getMessage()); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new ImageUploadException(FILE_SIZE_EXCEEDED.getMessage()); + } + } + + public void validateFileType(MultipartFile file) { + String contentType = file.getContentType(); + if (!isAllowedImageType(contentType)) { + throw new ImageUploadException(INVALID_FILE_TYPE.getMessage()); + } + } + + private boolean isAllowedImageType(String contentType) { + return contentType != null && ( + contentType.equals("image/jpeg") || + contentType.equals("image/png") || + contentType.equals("image/gif") || + contentType.equals("image/webp") + ); + } +} diff --git a/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java b/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java new file mode 100644 index 000000000..2481c5d48 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java @@ -0,0 +1,9 @@ +package com.somemore.imageupload.validator; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageUploadValidator { + + void validateFileSize(MultipartFile file); + void validateFileType(MultipartFile file); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 73394baa0..6621c5d18 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,20 @@ app: front-url: ${FRONT_URL} back-url: ${BACK_URL} +# AWS S3 +cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ${BUCKET_REGION} + s3: + bucket: ${BUCKET_NAME} + base-url: ${IMG_BASE_URL} + stack: + auto: false + spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -47,6 +61,12 @@ spring: locale: ko_KR locale-resolver: fixed + servlet: + multipart: + max-file-size: 8MB + max-request-size: 8MB + + #swagger springdoc: swagger-ui: diff --git a/src/test/java/com/somemore/community/repository/CommunityRepositoryTest.java b/src/test/java/com/somemore/community/repository/CommunityBoardRepositoryTest.java similarity index 98% rename from src/test/java/com/somemore/community/repository/CommunityRepositoryTest.java rename to src/test/java/com/somemore/community/repository/CommunityBoardRepositoryTest.java index f39083074..2f6fdbd52 100644 --- a/src/test/java/com/somemore/community/repository/CommunityRepositoryTest.java +++ b/src/test/java/com/somemore/community/repository/CommunityBoardRepositoryTest.java @@ -4,6 +4,7 @@ import com.somemore.auth.oauth.OAuthProvider; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.domain.CommunityBoardView; +import com.somemore.community.repository.board.CommunityBoardRepository; import com.somemore.volunteer.domain.Volunteer; import com.somemore.volunteer.repository.VolunteerRepository; import org.junit.jupiter.api.DisplayName; @@ -18,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; @Transactional -class CommunityRepositoryTest extends IntegrationTestSupport { +class CommunityBoardRepositoryTest extends IntegrationTestSupport { @Autowired private CommunityBoardRepository communityBoardRepository; diff --git a/src/test/java/com/somemore/community/repository/CommunityCommentRepositoryTest.java b/src/test/java/com/somemore/community/repository/CommunityCommentRepositoryTest.java new file mode 100644 index 000000000..e3ba059d5 --- /dev/null +++ b/src/test/java/com/somemore/community/repository/CommunityCommentRepositoryTest.java @@ -0,0 +1,111 @@ +package com.somemore.community.repository; + +import com.somemore.IntegrationTestSupport; +import com.somemore.community.domain.CommunityComment; +import com.somemore.community.repository.comment.CommunityCommentRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@Transactional +class CommunityCommentRepositoryTest extends IntegrationTestSupport { + @Autowired + CommunityCommentRepository communityCommentRepository; + + @DisplayName("커뮤니티 게시글에 댓글을 생성할 수 있다. (Repository)") + @Test + void createCommunityComment() { + + //given + UUID writerId = UUID.randomUUID(); + + CommunityComment communityComment = CommunityComment.builder() + .writerId(writerId) + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(null) + .build(); + + //when + CommunityComment savedComment = communityCommentRepository.save(communityComment); + + //then + assertThat(savedComment.getWriterId()).isEqualTo(writerId); + assertThat(savedComment.getContent()).isEqualTo("커뮤니티 댓글 테스트 내용"); + assertThat(savedComment.getParentCommentId()).isNull(); + } + + @DisplayName("댓글에 대댓글을 생성할 수 있다. (Repository)") + @Test + void createCommunityCommentReply() { + + //given + UUID writerId = UUID.randomUUID(); + + CommunityComment communityComment = CommunityComment.builder() + .writerId(writerId) + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(1L) + .build(); + + //when + CommunityComment savedComment = communityCommentRepository.save(communityComment); + + //then + assertThat(savedComment.getWriterId()).isEqualTo(writerId); + assertThat(savedComment.getContent()).isEqualTo("커뮤니티 댓글 테스트 내용"); + assertThat(savedComment.getParentCommentId()).isEqualTo(1L); + } + + @DisplayName("댓글을 id로 조회할 수 있다. (Repository)") + @Test + void findCommunityCommentById() { + + //given + UUID writerId = UUID.randomUUID(); + + CommunityComment communityComment = CommunityComment.builder() + .writerId(writerId) + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(null) + .build(); + + CommunityComment savedComment = communityCommentRepository.save(communityComment); + + //when + Optional comment = communityCommentRepository.findById(savedComment.getId()); + + //then + assertThat(comment).isPresent(); + assertThat(comment.get().getWriterId()).isEqualTo(writerId); + assertThat(comment.get().getContent()).isEqualTo("커뮤니티 댓글 테스트 내용"); + assertThat(comment.get().getParentCommentId()).isNull(); + } + + @DisplayName("댓글 id로 댓글이 존재하는지 확인할 수 있다.") + @Test + void existsById() { + + //given + UUID writerId = UUID.randomUUID(); + + CommunityComment communityComment = CommunityComment.builder() + .writerId(writerId) + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(null) + .build(); + + CommunityComment savedComment = communityCommentRepository.save(communityComment); + + //when + boolean isExist = communityCommentRepository.existsById(savedComment.getId()); + + //then + assertThat(isExist).isTrue(); + } +} diff --git a/src/test/java/com/somemore/community/service/CommunityBoardQueryServiceTest.java b/src/test/java/com/somemore/community/service/board/CommunityBoardQueryServiceTest.java similarity index 96% rename from src/test/java/com/somemore/community/service/CommunityBoardQueryServiceTest.java rename to src/test/java/com/somemore/community/service/board/CommunityBoardQueryServiceTest.java index 3d82d4281..e8c72be10 100644 --- a/src/test/java/com/somemore/community/service/CommunityBoardQueryServiceTest.java +++ b/src/test/java/com/somemore/community/service/board/CommunityBoardQueryServiceTest.java @@ -1,4 +1,4 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import com.somemore.IntegrationTestSupport; import com.somemore.auth.oauth.OAuthProvider; @@ -7,9 +7,9 @@ import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; import com.somemore.community.dto.response.CommunityBoardGetDetailResponseDto; import com.somemore.community.dto.response.CommunityBoardGetResponseDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.CreateCommunityBoardUseCase; -import com.somemore.community.usecase.DeleteCommunityBoardUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.CreateCommunityBoardUseCase; +import com.somemore.community.usecase.board.DeleteCommunityBoardUseCase; import com.somemore.global.exception.BadRequestException; import com.somemore.global.exception.ExceptionMessage; import com.somemore.volunteer.domain.Volunteer; diff --git a/src/test/java/com/somemore/community/service/CreateCommunityBoardServiceTest.java b/src/test/java/com/somemore/community/service/board/CreateCommunityBoardServiceTest.java similarity index 96% rename from src/test/java/com/somemore/community/service/CreateCommunityBoardServiceTest.java rename to src/test/java/com/somemore/community/service/board/CreateCommunityBoardServiceTest.java index d373da46a..ec9db956b 100644 --- a/src/test/java/com/somemore/community/service/CreateCommunityBoardServiceTest.java +++ b/src/test/java/com/somemore/community/service/board/CreateCommunityBoardServiceTest.java @@ -1,11 +1,11 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import static org.assertj.core.api.Assertions.assertThat; import com.somemore.IntegrationTestSupport; import com.somemore.community.domain.CommunityBoard; import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; -import com.somemore.community.repository.CommunityBoardRepository; +import com.somemore.community.repository.board.CommunityBoardRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/somemore/community/service/DeleteCommunityBoardServiceTest.java b/src/test/java/com/somemore/community/service/board/DeleteCommunityBoardServiceTest.java similarity index 92% rename from src/test/java/com/somemore/community/service/DeleteCommunityBoardServiceTest.java rename to src/test/java/com/somemore/community/service/board/DeleteCommunityBoardServiceTest.java index e72667431..8cc6a566e 100644 --- a/src/test/java/com/somemore/community/service/DeleteCommunityBoardServiceTest.java +++ b/src/test/java/com/somemore/community/service/board/DeleteCommunityBoardServiceTest.java @@ -1,13 +1,13 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import com.somemore.IntegrationTestSupport; import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.CreateCommunityBoardUseCase; -import com.somemore.community.usecase.CommunityBoardQueryUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.CreateCommunityBoardUseCase; +import com.somemore.community.usecase.board.CommunityBoardQueryUseCase; import com.somemore.global.exception.BadRequestException; import com.somemore.global.exception.ExceptionMessage; import org.assertj.core.api.ThrowableAssert; diff --git a/src/test/java/com/somemore/community/service/UpdateCommunityBoardServiceTest.java b/src/test/java/com/somemore/community/service/board/UpdateCommunityBoardServiceTest.java similarity index 95% rename from src/test/java/com/somemore/community/service/UpdateCommunityBoardServiceTest.java rename to src/test/java/com/somemore/community/service/board/UpdateCommunityBoardServiceTest.java index 06bd1de86..5e7f30264 100644 --- a/src/test/java/com/somemore/community/service/UpdateCommunityBoardServiceTest.java +++ b/src/test/java/com/somemore/community/service/board/UpdateCommunityBoardServiceTest.java @@ -1,4 +1,4 @@ -package com.somemore.community.service; +package com.somemore.community.service.board; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -7,8 +7,8 @@ import com.somemore.community.domain.CommunityBoard; import com.somemore.community.dto.request.CommunityBoardCreateRequestDto; import com.somemore.community.dto.request.CommunityBoardUpdateRequestDto; -import com.somemore.community.repository.CommunityBoardRepository; -import com.somemore.community.usecase.CreateCommunityBoardUseCase; +import com.somemore.community.repository.board.CommunityBoardRepository; +import com.somemore.community.usecase.board.CreateCommunityBoardUseCase; import com.somemore.global.exception.BadRequestException; import com.somemore.global.exception.ExceptionMessage; import org.assertj.core.api.ThrowableAssert; diff --git a/src/test/java/com/somemore/community/service/comment/CreateCommunityCommentServiceTest.java b/src/test/java/com/somemore/community/service/comment/CreateCommunityCommentServiceTest.java new file mode 100644 index 000000000..bc955770c --- /dev/null +++ b/src/test/java/com/somemore/community/service/comment/CreateCommunityCommentServiceTest.java @@ -0,0 +1,107 @@ +package com.somemore.community.service.comment; + +import com.somemore.IntegrationTestSupport; +import com.somemore.community.domain.CommunityComment; +import com.somemore.community.dto.request.CommunityCommentCreateRequestDto; +import com.somemore.community.repository.comment.CommunityCommentRepository; +import com.somemore.global.exception.BadRequestException; +import com.somemore.global.exception.ExceptionMessage; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class CreateCommunityCommentServiceTest extends IntegrationTestSupport { + + @Autowired + private CreateCommunityCommentService createCommunityCommentService; + @Autowired + private CommunityCommentRepository communityCommentRepository; + + @AfterEach + void tearDown() { + communityCommentRepository.deleteAllInBatch(); + } + + @DisplayName("커뮤니티 게시글에 댓글을 등록한다.") + @Test + void createCommunityCommentWithDto() { + + //given + CommunityCommentCreateRequestDto dto = CommunityCommentCreateRequestDto.builder() + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(null) + .build(); + + UUID writerId = UUID.randomUUID(); + + //when + Long commentId = createCommunityCommentService.createCommunityComment(dto, writerId); + + //then + Optional communityComment = communityCommentRepository.findById(commentId); + + assertThat(communityComment).isPresent(); + assertThat(communityComment.get().getId()).isEqualTo(commentId); + assertThat(communityComment.get().getWriterId()).isEqualTo(writerId); + assertThat(communityComment.get().getContent()).isEqualTo("커뮤니티 댓글 테스트 내용"); + assertThat(communityComment.get().getParentCommentId()).isNull(); + } + + @DisplayName("댓글에 대댓글을 등록한다.") + @Test + void createCommunityCommentRelyWithDto() { + + //given + CommunityCommentCreateRequestDto commentDto = CommunityCommentCreateRequestDto.builder() + .content("커뮤니티 댓글 테스트 내용") + .parentCommentId(null) + .build(); + + UUID writerId = UUID.randomUUID(); + Long commentId = createCommunityCommentService.createCommunityComment(commentDto, writerId); + + CommunityCommentCreateRequestDto replyDto = CommunityCommentCreateRequestDto.builder() + .content("커뮤니티 대댓글 테스트 내용") + .parentCommentId(commentId) + .build(); + + //when + Long replyCommentId = createCommunityCommentService.createCommunityComment(replyDto, writerId); + + //then + Optional communityCommentReply = communityCommentRepository.findById(replyCommentId); + + assertThat(communityCommentReply).isPresent(); + assertThat(communityCommentReply.get().getId()).isEqualTo(replyCommentId); + assertThat(communityCommentReply.get().getWriterId()).isEqualTo(writerId); + assertThat(communityCommentReply.get().getContent()).isEqualTo("커뮤니티 대댓글 테스트 내용"); + assertThat(communityCommentReply.get().getParentCommentId()).isEqualTo(commentId); + } + + @DisplayName("삭제된 댓글에 대댓글을 등록할 때 예외를 던진다.") + @Test + void createCommunityCommentReplyWithDeletedParentId() { + + //given + CommunityCommentCreateRequestDto replyDto = CommunityCommentCreateRequestDto.builder() + .content("커뮤니티 대댓글 테스트 내용") + .parentCommentId(2L) + .build(); + + //when + ThrowableAssert.ThrowingCallable callable = () -> createCommunityCommentService.createCommunityComment(replyDto, UUID.randomUUID()); + + //then + assertThatExceptionOfType(BadRequestException.class) + .isThrownBy(callable) + .withMessage(ExceptionMessage.NOT_EXISTS_COMMUNITY_COMMENT.getMessage()); + } +} diff --git a/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java new file mode 100644 index 000000000..31e2fbfd6 --- /dev/null +++ b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java @@ -0,0 +1,79 @@ +package com.somemore.imageupload.service; + +import com.somemore.IntegrationTestSupport; +import com.somemore.global.exception.ImageUploadException; +import com.somemore.imageupload.dto.ImageUploadRequestDto; +import com.somemore.imageupload.validator.ImageUploadValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ImageUploadServiceTest extends IntegrationTestSupport { + + @Mock + private S3Client s3Client; + + @Mock + private ImageUploadValidator imageUploadValidator; + + @InjectMocks + private ImageUploadService imageUploadService; + + @Mock + private MultipartFile multipartFile; + + @BeforeEach + void setUp() throws IOException { + ReflectionTestUtils.setField(imageUploadService, "bucket", "test-bucket"); + ReflectionTestUtils.setField(imageUploadService, "baseUrl", "https://amazonaws.com/"); + + when(multipartFile.getOriginalFilename()).thenReturn("testImage.jpg"); + when(multipartFile.getContentType()).thenReturn("image/jpeg"); + when(multipartFile.getInputStream()).thenReturn(mock(InputStream.class)); + when(multipartFile.getSize()).thenReturn(1000L); + } + + @DisplayName("업로드 요청 이미지를 S3에 업로드 할 수 있다.") + @Test + void testUploadImage_success() { + // given + ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(null); + + // when + String result = imageUploadService.uploadImage(requestDto); + + // then + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + assertNotNull(result); + assertTrue(result.startsWith("https://amazonaws.com/")); + assertTrue(result.endsWith(".jpg")); + } + + @DisplayName("이미지 형식이 올바르지 않다면 업로드 할 수 없다.") + @Test + void testUploadImage_failure() throws IOException { + // given + when(multipartFile.getInputStream()).thenThrow(new IOException()); + + ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile); + + // when, then + assertThrows(ImageUploadException.class, () -> imageUploadService.uploadImage(requestDto)); + } +} diff --git a/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java new file mode 100644 index 000000000..15180389b --- /dev/null +++ b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java @@ -0,0 +1,84 @@ +package com.somemore.imageupload.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import static org.junit.jupiter.api.Assertions.*; + +class ImageUploadUtilsTest { + + @Test + void privateConstructorShouldThrowException() throws Exception { + // given + Constructor constructor = ImageUploadUtils.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // when + InvocationTargetException exception = assertThrows(InvocationTargetException.class, constructor::newInstance); + + // then + assertThrows(UnsupportedOperationException.class, () -> { throw exception.getCause(); }); + } + + @DisplayName("이미지 업로드시 유일한 이미지 이름을 만들어줄 수 있다.") + @Test + void testGenerateUniqueFileName() { + //given + String fileName = "image.png"; + + //when + String uniqueName = ImageUploadUtils.generateUniqueFileName(fileName); + + //then + assertTrue(uniqueName.endsWith(".png")); + assertNotEquals(fileName, uniqueName); + } + + @DisplayName("유니크한 파일 이름을 생성할 때 UUID는 정상적으로 생성된다.") + @Test + void testGenerateUniqueFileName_uuid() { + // given + String fileName = "image.png"; + + // when + String uniqueName = ImageUploadUtils.generateUniqueFileName(fileName); + + // then + assertNotNull(uniqueName); + assertTrue(uniqueName.contains("-")); + assertTrue(uniqueName.endsWith(".png")); + } + + + @DisplayName("이미지의 주소를 반환할 수 있다.") + @Test + void testGenerateS3Url() { + //given + String baseUrl = "https://amazonaws.com"; + String fileName = "unique-image.png"; + + //when + String url = ImageUploadUtils.generateS3Url(baseUrl, fileName); + + //then + assertEquals("https://amazonaws.com/unique-image.png", url); + } + + @DisplayName("baseUrl이 빈 문자열일 경우 URL을 생성할 수 있다.") + @Test + void testGenerateS3Url_emptyBaseUrl() { + // given + String baseUrl = ""; + String fileName = "unique-image.png"; + + // when + String url = ImageUploadUtils.generateS3Url(baseUrl, fileName); + + // then + assertEquals("/unique-image.png", url); + } + +} \ No newline at end of file diff --git a/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java new file mode 100644 index 000000000..03c2d6b0e --- /dev/null +++ b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java @@ -0,0 +1,86 @@ +package com.somemore.imageupload.validator; + +import com.somemore.global.exception.ImageUploadException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultImageUploadValidatorTest { + + private DefaultImageUploadValidator imageUploadValidator; + + @BeforeEach + void setUp() { + // given + imageUploadValidator = new DefaultImageUploadValidator(); + } + + @Test + @DisplayName("파일이 비어있으면 예외가 발생한다.") + void shouldThrowExceptionWhenFileIsEmpty() { + //given + MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]); + + //when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(emptyFile)); + + //then + assertEquals(ImageUploadException.class, exception.getClass()); + } + + @Test + @DisplayName("파일 크기가 최대 8MB를 초과하는 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileSizeExceeded() { + // given + MultipartFile largeFile = new MockMultipartFile("file", "largeImage.jpg", "image/jpeg", new byte[9 * 1024 * 1024]); + + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(largeFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); + } + + @Test + @DisplayName("유효한 이미지 타입(JPEG) 파일은, 검증에 통과한다.") + void shouldNotThrowExceptionWhenFileTypeIsValidJpeg() { + // given + MultipartFile validFile = new MockMultipartFile("file", "validImage.jpg", "image/jpeg", new byte[1024]); + + // when + imageUploadValidator.validateFileType(validFile); + + // then + assertDoesNotThrow(() -> imageUploadValidator.validateFileType(validFile)); + } + + @Test + @DisplayName("유효하지 않은 이미지 타입 파일이 있을 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileTypeIsInvalid() { + // given + MultipartFile invalidFile = new MockMultipartFile("file", "invalidFile.pdf", "application/pdf", new byte[1024]); + + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(invalidFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); + } + + @Test + @DisplayName("파일 타입이 올바르지 않을 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileTypeIsNull() { + // given + MultipartFile nullContentTypeFile = new MockMultipartFile("file", "noContentTypeFile.jpg", null, new byte[1024]); + + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(nullContentTypeFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index fab7dd965..50d9922c6 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -44,3 +44,17 @@ server: charset: UTF-8 enabled: true force: true + +cloud: + aws: + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + s3: + bucket: somemore + base-url: https://somemore-image.s3.ap-northeast-2.amazonaws.com + stack: + auto: false +