diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 2dea7c170..d7d2a6577 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,18 +1,56 @@ name: CD - Development on: - workflow_run: - workflows: [ "CI" ] + push: branches: [ "develop" ] - types: [ completed ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE' + - '.env.example' concurrency: group: deploy-dev cancel-in-progress: false jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + + - name: Run tests + run: ./gradlew test + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + build/reports/tests/ + build/test-results/ + retention-days: 7 + deploy: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + needs: test runs-on: ubuntu-latest timeout-minutes: 30 environment: development @@ -22,8 +60,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v5 @@ -41,11 +77,6 @@ jobs: mkdir -p secrets echo "${{ secrets.FIREBASE_JSON }}" | base64 -d > secrets/deepple-firebase.json echo "${{ secrets.GOOGLE_PLAY_JSON }}" | base64 -d > secrets/deepple-google-play.json - echo "[DEBUG] secrets/ directory contents:" - ls -la secrets/ - echo "[DEBUG] GOOGLE_PLAY_JSON secret length: ${#GOOGLE_PLAY_JSON_DEBUG}" - env: - GOOGLE_PLAY_JSON_DEBUG: ${{ secrets.GOOGLE_PLAY_JSON }} - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 @@ -57,7 +88,7 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }} + ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:dev-${{ github.sha }} ${{ steps.login-ecr.outputs.registry }}/${{ vars.ECR_REPOSITORY }}:dev-latest cache-from: type=gha cache-to: type=gha,mode=max @@ -66,10 +97,10 @@ jobs: run: | # 배포 스크립트 base64 인코딩 DEPLOY_SCRIPT_B64=$(base64 -w 0 .github/scripts/deploy-dev.sh) - + # 환경변수 파일 base64 인코딩 ENV_CONTENT_B64=$(echo -n '${{ secrets.ENV }}' | base64 -w 0) - + # SSM 명령 실행 COMMAND_ID=$(aws ssm send-command \ --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \ @@ -78,24 +109,24 @@ jobs: --parameters commands="[ \"echo ${DEPLOY_SCRIPT_B64} | base64 -d > /tmp/deploy.sh\", \"chmod +x /tmp/deploy.sh\", - \"/tmp/deploy.sh '${ENV_CONTENT_B64}' '${{ vars.AWS_REGION }}' '${{ steps.login-ecr.outputs.registry }}' '${{ vars.ECR_REPOSITORY }}' 'dev-${{ github.event.workflow_run.head_sha }}' '${{ vars.CONTAINER_NAME }}' '${{ vars.BLUE_PORT }}' '${{ vars.GREEN_PORT }}' '${{ vars.HEALTH_CHECK_MAX_RETRIES }}' '${{ vars.HEALTH_CHECK_INTERVAL }}'\" + \"/tmp/deploy.sh '${ENV_CONTENT_B64}' '${{ vars.AWS_REGION }}' '${{ steps.login-ecr.outputs.registry }}' '${{ vars.ECR_REPOSITORY }}' 'dev-${{ github.sha }}' '${{ vars.CONTAINER_NAME }}' '${{ vars.BLUE_PORT }}' '${{ vars.GREEN_PORT }}' '${{ vars.HEALTH_CHECK_MAX_RETRIES }}' '${{ vars.HEALTH_CHECK_INTERVAL }}'\" ]" \ --query "Command.CommandId" \ --output text) - + echo "Command ID: $COMMAND_ID" echo "Waiting for deployment..." - + # 결과 폴링 for i in {1..120}; do sleep 5 - + RESULT=$(aws ssm get-command-invocation \ --command-id "$COMMAND_ID" \ --instance-id "${{ secrets.EC2_INSTANCE_ID }}" 2>/dev/null) || continue - + STATUS=$(echo "$RESULT" | jq -r '.Status') - + case "$STATUS" in Success) echo "" @@ -123,6 +154,6 @@ jobs: ;; esac done - + echo "Timeout waiting for deployment result" exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d2b698f..760bb9513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: - '.env.example' push: - branches: [ "develop", "main" ] + branches: [ "main" ] paths-ignore: - '**.md' - 'docs/**' diff --git a/src/main/java/deepple/deepple/member/command/infra/profileImage/S3Uploader.java b/src/main/java/deepple/deepple/common/infra/s3/S3Uploader.java similarity index 67% rename from src/main/java/deepple/deepple/member/command/infra/profileImage/S3Uploader.java rename to src/main/java/deepple/deepple/common/infra/s3/S3Uploader.java index c369a3cf7..9e0916153 100644 --- a/src/main/java/deepple/deepple/member/command/infra/profileImage/S3Uploader.java +++ b/src/main/java/deepple/deepple/common/infra/s3/S3Uploader.java @@ -1,6 +1,6 @@ -package deepple.deepple.member.command.infra.profileImage; +package deepple.deepple.common.infra.s3; -import deepple.deepple.member.command.infra.profileImage.dto.PresignedUrlResponse; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import io.awspring.cloud.s3.S3Template; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -38,6 +38,18 @@ public PresignedUrlResponse getPresignedUrl(String fileName, Long memberId) { return new PresignedUrlResponse(presignedUrl.toString(), objectUrl); } + public PresignedUrlResponse getPresignedUrl(String fileName, Long memberId, String pathPrefix) { + String key = pathPrefix + "/" + generateUniqueKey(fileName, memberId); + URL presignedUrl = s3Template.createSignedPutURL( + bucket, + key, + Duration.ofMinutes(PRESIGNED_URL_EXPIRATION_MINUTES) + ); + String objectUrl = "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key; + + return new PresignedUrlResponse(presignedUrl.toString(), objectUrl); + } + private String generateUniqueKey(String fileName, Long memberId) { String extension = fileName.substring(fileName.lastIndexOf(".")); return memberId + "/" + UUID.randomUUID() + extension; diff --git a/src/main/java/deepple/deepple/member/command/infra/profileImage/dto/PresignedUrlResponse.java b/src/main/java/deepple/deepple/common/infra/s3/dto/PresignedUrlResponse.java similarity index 58% rename from src/main/java/deepple/deepple/member/command/infra/profileImage/dto/PresignedUrlResponse.java rename to src/main/java/deepple/deepple/common/infra/s3/dto/PresignedUrlResponse.java index bef741324..911665547 100644 --- a/src/main/java/deepple/deepple/member/command/infra/profileImage/dto/PresignedUrlResponse.java +++ b/src/main/java/deepple/deepple/common/infra/s3/dto/PresignedUrlResponse.java @@ -1,4 +1,4 @@ -package deepple.deepple.member.command.infra.profileImage.dto; +package deepple.deepple.common.infra.s3.dto; public record PresignedUrlResponse( String presignedUrl, diff --git a/src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3AmazonException.java b/src/main/java/deepple/deepple/common/infra/s3/exception/S3AmazonException.java similarity index 86% rename from src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3AmazonException.java rename to src/main/java/deepple/deepple/common/infra/s3/exception/S3AmazonException.java index 6da904993..29ad258a7 100644 --- a/src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3AmazonException.java +++ b/src/main/java/deepple/deepple/common/infra/s3/exception/S3AmazonException.java @@ -1,4 +1,4 @@ -package deepple.deepple.member.command.infra.profileImage.exception; +package deepple.deepple.common.infra.s3.exception; import software.amazon.awssdk.awscore.exception.AwsServiceException; diff --git a/src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3ClientException.java b/src/main/java/deepple/deepple/common/infra/s3/exception/S3ClientException.java similarity index 87% rename from src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3ClientException.java rename to src/main/java/deepple/deepple/common/infra/s3/exception/S3ClientException.java index 93bdd26de..5445e3c31 100644 --- a/src/main/java/deepple/deepple/member/command/infra/profileImage/exception/S3ClientException.java +++ b/src/main/java/deepple/deepple/common/infra/s3/exception/S3ClientException.java @@ -1,4 +1,4 @@ -package deepple.deepple.member.command.infra.profileImage.exception; +package deepple.deepple.common.infra.s3.exception; import software.amazon.awssdk.core.exception.SdkClientException; diff --git a/src/main/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionService.java b/src/main/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionService.java index d8db0f71d..a02c182b0 100644 --- a/src/main/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionService.java +++ b/src/main/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionService.java @@ -1,5 +1,7 @@ package deepple.deepple.community.command.application.selfintroduction; +import deepple.deepple.common.infra.s3.S3Uploader; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import deepple.deepple.community.command.application.selfintroduction.exception.NotSelfIntroductionAuthorException; import deepple.deepple.community.command.application.selfintroduction.exception.SelfIntroductionNotFoundException; import deepple.deepple.community.command.domain.selfintroduction.SelfIntroduction; @@ -14,13 +16,17 @@ @Service @RequiredArgsConstructor public class SelfIntroductionService { + private static final String IMAGE_PATH_PREFIX = "self-introduction"; + private final SelfIntroductionCommandRepository selfIntroductionCommandRepository; private final MemberCommandRepository memberCommandRepository; + private final S3Uploader s3Uploader; @Transactional public void write(SelfIntroductionWriteRequest request, Long memberId) { validateMemberId(memberId); - SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, request.title(), request.content()); + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, request.title(), request.content(), + request.imageUrl()); selfIntroductionCommandRepository.save(selfIntroduction); } @@ -30,7 +36,7 @@ public void update(SelfIntroductionWriteRequest request, Long memberId, Long id) SelfIntroduction selfIntroduction = getSelfIntroductionById(id); validateSelfIntroductionAuthor(selfIntroduction.getMemberId(), memberId); - selfIntroduction.update(request.title(), request.content()); + selfIntroduction.update(request.title(), request.content(), request.imageUrl()); } @Transactional @@ -40,6 +46,11 @@ public void delete(Long id, Long memberId) { selfIntroductionCommandRepository.deleteById(id); } + public PresignedUrlResponse getPresignedUrl(String fileName, Long memberId) { + validateMemberId(memberId); + return s3Uploader.getPresignedUrl(fileName, memberId, IMAGE_PATH_PREFIX); + } + @Transactional public void changeOpenStatus(Long id, boolean open) { SelfIntroduction selfIntroduction = getSelfIntroductionById(id); diff --git a/src/main/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroduction.java b/src/main/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroduction.java index 70337070e..45b7d38c3 100644 --- a/src/main/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroduction.java +++ b/src/main/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroduction.java @@ -3,6 +3,7 @@ import deepple.deepple.common.entity.SoftDeleteBaseEntity; import deepple.deepple.community.command.domain.selfintroduction.exception.InvalidSelfIntroductionContentException; import deepple.deepple.community.command.domain.selfintroduction.exception.InvalidSelfIntroductionTitleException; +import deepple.deepple.member.command.domain.profileImage.exception.InvalidImageUrlException; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -32,23 +33,30 @@ public class SelfIntroduction extends SoftDeleteBaseEntity { @Getter private String content; + @Getter + @Column(name = "image_url") + private String imageUrl; + @Getter private boolean isOpened; - private SelfIntroduction(@NonNull Long memberId, @NonNull String title, @NonNull String content, boolean isOpened) { + private SelfIntroduction(@NonNull Long memberId, @NonNull String title, @NonNull String content, String imageUrl, + boolean isOpened) { this.isOpened = isOpened; this.memberId = memberId; setTitle(title); setContent(content); + setImageUrl(imageUrl); } - public static SelfIntroduction write(Long memberId, String title, String content) { - return new SelfIntroduction(memberId, title, content, false); + public static SelfIntroduction write(Long memberId, String title, String content, String imageUrl) { + return new SelfIntroduction(memberId, title, content, imageUrl, false); } - public void update(String title, String content) { + public void update(String title, String content, String imageUrl) { setTitle(title); setContent(content); + setImageUrl(imageUrl); } public void close() { @@ -69,6 +77,13 @@ private void setContent(@NonNull String content) { this.content = content; } + private void setImageUrl(String imageUrl) { + if (imageUrl != null && imageUrl.isEmpty()) { + throw new InvalidImageUrlException(); + } + this.imageUrl = imageUrl; + } + private void validateContent(String content) { if (content.isBlank() || content.length() < 30) { throw new InvalidSelfIntroductionContentException(); diff --git a/src/main/java/deepple/deepple/community/presentation/selfintroduction/SelfIntroductionController.java b/src/main/java/deepple/deepple/community/presentation/selfintroduction/SelfIntroductionController.java index e68812d61..6f4e749a0 100644 --- a/src/main/java/deepple/deepple/community/presentation/selfintroduction/SelfIntroductionController.java +++ b/src/main/java/deepple/deepple/community/presentation/selfintroduction/SelfIntroductionController.java @@ -3,6 +3,7 @@ import deepple.deepple.auth.presentation.AuthContext; import deepple.deepple.auth.presentation.AuthPrincipal; import deepple.deepple.common.enums.StatusType; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import deepple.deepple.common.response.BaseResponse; import deepple.deepple.community.command.application.selfintroduction.SelfIntroductionService; import deepple.deepple.community.presentation.selfintroduction.dto.SelfIntroductionSearchCondition; @@ -11,6 +12,7 @@ import deepple.deepple.community.query.selfintroduction.SelfIntroductionQueryRepository; import deepple.deepple.community.query.selfintroduction.view.SelfIntroductionSummaryView; import deepple.deepple.community.query.selfintroduction.view.SelfIntroductionView; +import deepple.deepple.member.presentation.profileimage.dto.PresignedUrlPostRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -38,6 +40,17 @@ public ResponseEntity> write(@RequestBody @Valid SelfIntroduc return ResponseEntity.ok(BaseResponse.from(StatusType.OK)); } + @Operation(summary = "셀프 소개 이미지 업로드용 preSignedUrl 생성") + @PostMapping("/presigned-url") + public ResponseEntity> getPresignedUrl( + @RequestBody @Valid PresignedUrlPostRequest request, + @AuthPrincipal AuthContext authContext) { + return ResponseEntity.ok( + BaseResponse.of(StatusType.OK, + selfIntroductionService.getPresignedUrl(request.fileName(), authContext.getId())) + ); + } + @Operation(summary = "셀프 소개 수정 API") @PatchMapping("/{id}") public ResponseEntity> update(@PathVariable Long id, diff --git a/src/main/java/deepple/deepple/community/presentation/selfintroduction/dto/SelfIntroductionWriteRequest.java b/src/main/java/deepple/deepple/community/presentation/selfintroduction/dto/SelfIntroductionWriteRequest.java index bde40d382..866e57bff 100644 --- a/src/main/java/deepple/deepple/community/presentation/selfintroduction/dto/SelfIntroductionWriteRequest.java +++ b/src/main/java/deepple/deepple/community/presentation/selfintroduction/dto/SelfIntroductionWriteRequest.java @@ -5,6 +5,7 @@ public record SelfIntroductionWriteRequest( @NotBlank String title, - @Size(min = 30, message = "내용은 최소 30자 이상이어야 합니다.") String content + @Size(min = 30, message = "내용은 최소 30자 이상이어야 합니다.") String content, + String imageUrl ) { } diff --git a/src/main/java/deepple/deepple/community/query/selfintroduction/AdminSelfIntroductionQueryRepository.java b/src/main/java/deepple/deepple/community/query/selfintroduction/AdminSelfIntroductionQueryRepository.java index ccc9472ba..f37acb3ee 100644 --- a/src/main/java/deepple/deepple/community/query/selfintroduction/AdminSelfIntroductionQueryRepository.java +++ b/src/main/java/deepple/deepple/community/query/selfintroduction/AdminSelfIntroductionQueryRepository.java @@ -38,6 +38,7 @@ public Page findSelfIntroductions(AdminSelfIntroducti member.profile.gender.stringValue(), selfIntroduction.isOpened, selfIntroduction.content, + selfIntroduction.imageUrl, selfIntroduction.createdAt, selfIntroduction.updatedAt, selfIntroduction.deletedAt diff --git a/src/main/java/deepple/deepple/community/query/selfintroduction/SelfIntroductionQueryRepository.java b/src/main/java/deepple/deepple/community/query/selfintroduction/SelfIntroductionQueryRepository.java index edcfd494a..51dd435bd 100644 --- a/src/main/java/deepple/deepple/community/query/selfintroduction/SelfIntroductionQueryRepository.java +++ b/src/main/java/deepple/deepple/community/query/selfintroduction/SelfIntroductionQueryRepository.java @@ -49,6 +49,7 @@ public List findSelfIntroductions(SelfIntroductionS member.profile.yearOfBirth.value, selfIntroduction.title, selfIntroduction.content, + selfIntroduction.imageUrl, selfIntroduction.createdAt ) ) @@ -77,6 +78,7 @@ public List findMySelfIntroductions(Long lastId, lo member.profile.yearOfBirth.value, selfIntroduction.title, selfIntroduction.content, + selfIntroduction.imageUrl, selfIntroduction.createdAt ) ) @@ -118,6 +120,7 @@ public Optional findSelfIntroductionByIdWithMemberId(Long like.level.stringValue(), selfIntroduction.title, selfIntroduction.content, + selfIntroduction.imageUrl, profileExchange.status.stringValue(), selfIntroduction.createdAt ) diff --git a/src/main/java/deepple/deepple/community/query/selfintroduction/view/AdminSelfIntroductionView.java b/src/main/java/deepple/deepple/community/query/selfintroduction/view/AdminSelfIntroductionView.java index 3952a65ad..87699f56e 100644 --- a/src/main/java/deepple/deepple/community/query/selfintroduction/view/AdminSelfIntroductionView.java +++ b/src/main/java/deepple/deepple/community/query/selfintroduction/view/AdminSelfIntroductionView.java @@ -14,6 +14,7 @@ public record AdminSelfIntroductionView( String gender, boolean isOpened, String content, + String imageUrl, String createdDate, String updatedDate, String deletedDate @@ -25,6 +26,7 @@ public AdminSelfIntroductionView( String gender, boolean isOpened, String content, + String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt @@ -35,6 +37,7 @@ public AdminSelfIntroductionView( gender, isOpened, content, + imageUrl, formatDate(createdAt), formatDate(updatedAt), formatDate(deletedAt) diff --git a/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionSummaryView.java b/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionSummaryView.java index 9ef052c4a..cd647207c 100644 --- a/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionSummaryView.java +++ b/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionSummaryView.java @@ -11,6 +11,7 @@ public record SelfIntroductionSummaryView( Integer yearOfBirth, String title, String content, + String imageUrl, LocalDateTime createdAt ) { @QueryProjection diff --git a/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionView.java b/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionView.java index f15437469..d551fa4ad 100644 --- a/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionView.java +++ b/src/main/java/deepple/deepple/community/query/selfintroduction/view/SelfIntroductionView.java @@ -14,6 +14,7 @@ public record SelfIntroductionView( String like, String title, String content, + String imageUrl, String profileExchangeStatus, LocalDateTime createdAt ) { @@ -30,10 +31,11 @@ public SelfIntroductionView(Long memberId, String like, String title, String content, + String imageUrl, String profileExchangeStatus, LocalDateTime createdAt ) { this(new MemberBasicInfo(memberId, nickname, AgeConverter.toAge(yearOfBirth), profileImageUrl, city, district, - mbti, hobbies, gender), like, title, content, profileExchangeStatus, createdAt); + mbti, hobbies, gender), like, title, content, imageUrl, profileExchangeStatus, createdAt); } } diff --git a/src/main/java/deepple/deepple/member/command/application/profileImage/ProfileImageService.java b/src/main/java/deepple/deepple/member/command/application/profileImage/ProfileImageService.java index e99a9274d..c65c636bc 100644 --- a/src/main/java/deepple/deepple/member/command/application/profileImage/ProfileImageService.java +++ b/src/main/java/deepple/deepple/member/command/application/profileImage/ProfileImageService.java @@ -1,13 +1,13 @@ package deepple.deepple.member.command.application.profileImage; +import deepple.deepple.common.infra.s3.S3Uploader; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import deepple.deepple.member.command.application.profileImage.dto.ProfileImageUploadResponse; import deepple.deepple.member.command.application.profileImage.exception.ExceedProfileImageCountException; import deepple.deepple.member.command.application.profileImage.exception.InvalidProfileImageExtensionException; import deepple.deepple.member.command.domain.profileImage.ProfileImage; import deepple.deepple.member.command.domain.profileImage.ProfileImageCommandRepository; import deepple.deepple.member.command.domain.profileImage.vo.ImageUrl; -import deepple.deepple.member.command.infra.profileImage.S3Uploader; -import deepple.deepple.member.command.infra.profileImage.dto.PresignedUrlResponse; import deepple.deepple.member.presentation.profileimage.dto.ProfileImageUploadRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/deepple/deepple/member/presentation/profileimage/ProfileImageController.java b/src/main/java/deepple/deepple/member/presentation/profileimage/ProfileImageController.java index 0a799df94..95436230e 100644 --- a/src/main/java/deepple/deepple/member/presentation/profileimage/ProfileImageController.java +++ b/src/main/java/deepple/deepple/member/presentation/profileimage/ProfileImageController.java @@ -3,10 +3,10 @@ import deepple.deepple.auth.presentation.AuthContext; import deepple.deepple.auth.presentation.AuthPrincipal; import deepple.deepple.common.enums.StatusType; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import deepple.deepple.common.response.BaseResponse; import deepple.deepple.member.command.application.profileImage.ProfileImageService; import deepple.deepple.member.command.application.profileImage.dto.ProfileImageUploadResponse; -import deepple.deepple.member.command.infra.profileImage.dto.PresignedUrlResponse; import deepple.deepple.member.presentation.profileimage.dto.PresignedUrlPostRequest; import deepple.deepple.member.presentation.profileimage.dto.ProfileImageUploadRequestWrapper; import deepple.deepple.member.query.profileimage.ProfileImageQueryRepository; diff --git a/src/main/resources/db/migration/V15__add_image_url_to_self_introductions.sql b/src/main/resources/db/migration/V15__add_image_url_to_self_introductions.sql new file mode 100644 index 000000000..8700f51f2 --- /dev/null +++ b/src/main/resources/db/migration/V15__add_image_url_to_self_introductions.sql @@ -0,0 +1,2 @@ +ALTER TABLE self_introductions + ADD COLUMN image_url VARCHAR(255); diff --git a/src/test/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionServiceTest.java b/src/test/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionServiceTest.java index f277f3464..57131d5c1 100644 --- a/src/test/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionServiceTest.java +++ b/src/test/java/deepple/deepple/community/command/application/selfintroduction/SelfIntroductionServiceTest.java @@ -1,5 +1,7 @@ package deepple.deepple.community.command.application.selfintroduction; +import deepple.deepple.common.infra.s3.S3Uploader; +import deepple.deepple.common.infra.s3.dto.PresignedUrlResponse; import deepple.deepple.community.command.application.selfintroduction.exception.NotSelfIntroductionAuthorException; import deepple.deepple.community.command.application.selfintroduction.exception.SelfIntroductionNotFoundException; import deepple.deepple.community.command.domain.selfintroduction.SelfIntroduction; @@ -20,7 +22,7 @@ import java.util.Optional; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -32,6 +34,9 @@ public class SelfIntroductionServiceTest { @Mock private MemberCommandRepository memberCommandRepository; + @Mock + private S3Uploader s3Uploader; + @InjectMocks private SelfIntroductionService selfIntroductionService; @@ -50,7 +55,8 @@ void throwExceptionWhenMemberNotFound() { // When & Then Assertions.assertThatThrownBy( - () -> selfIntroductionService.write(new SelfIntroductionWriteRequest(title, content), memberId)) + () -> selfIntroductionService.write(new SelfIntroductionWriteRequest(title, content, null), + memberId)) .isInstanceOf(MemberNotFoundException.class); } @@ -66,13 +72,35 @@ void writeSelfIntroduction() { .thenReturn(Optional.of(Mockito.mock(Member.class))); // When - selfIntroductionService.write(new SelfIntroductionWriteRequest(title, content), memberId); + selfIntroductionService.write(new SelfIntroductionWriteRequest(title, content, null), memberId); // Then verify(selfIntroductionCommandRepository).save(argThat(selfIntroduction -> selfIntroduction.getMemberId().equals(memberId) && selfIntroduction.getContent().equals(content) && - selfIntroduction.getTitle().equals(title) + selfIntroduction.getTitle().equals(title) && + selfIntroduction.getImageUrl() == null + )); + } + + @DisplayName("이미지 URL을 포함하여 셀프 소개를 작성한다.") + @Test + void writeSelfIntroductionWithImage() { + // Given + Long memberId = 1L; + String title = "셀프 소개 제목"; + String content = "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)"; + String imageUrl = "https://example.com/image.jpg"; + + Mockito.when(memberCommandRepository.findById(memberId)) + .thenReturn(Optional.of(Mockito.mock(Member.class))); + + // When + selfIntroductionService.write(new SelfIntroductionWriteRequest(title, content, imageUrl), memberId); + + // Then + verify(selfIntroductionCommandRepository).save(argThat(selfIntroduction -> + imageUrl.equals(selfIntroduction.getImageUrl()) )); } } @@ -94,8 +122,8 @@ void throwExceptionWhenMemberNotFound() { // When & Then Assertions.assertThatThrownBy( - () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content), memberId, - selfIntroductionId)) + () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content, null), + memberId, selfIntroductionId)) .isInstanceOf(MemberNotFoundException.class); } @@ -114,8 +142,8 @@ void throwExceptionWhenSelfIntroductionNotFound() { // When & Then Assertions.assertThatThrownBy( - () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content), memberId, - selfIntroductionId)) + () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content, null), + memberId, selfIntroductionId)) .isInstanceOf(SelfIntroductionNotFoundException.class); } @@ -128,7 +156,7 @@ void throwExceptionWhenMemberIdFromSelfIntroductionIsNotEqualMemberId() { String title = "셀프 소개 제목"; String content = "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)"; - SelfIntroduction selfIntroduction = SelfIntroduction.write(1L, title, content); + SelfIntroduction selfIntroduction = SelfIntroduction.write(1L, title, content, null); Mockito.when(memberCommandRepository.findById(memberId)) .thenReturn(Optional.of(Mockito.mock(Member.class))); @@ -137,8 +165,8 @@ void throwExceptionWhenMemberIdFromSelfIntroductionIsNotEqualMemberId() { // When & Then Assertions.assertThatThrownBy( - () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content), memberId, - selfIntroductionId)) + () -> selfIntroductionService.update(new SelfIntroductionWriteRequest(title, content, null), + memberId, selfIntroductionId)) .isInstanceOf(NotSelfIntroductionAuthorException.class); } @@ -151,8 +179,9 @@ void updateSelfIntroduction() { String title = "셀프 소개 제목"; String content = "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)"; String updatedContent = "셀프 소개 내용입니다. 최소 내용이 100자 이상입니다~!!! (100자 이상)"; + String newImageUrl = "https://example.com/image.jpg"; - SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content); + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content, null); Mockito.when(memberCommandRepository.findById(memberId)) .thenReturn(Optional.of(Mockito.mock(Member.class))); @@ -160,13 +189,14 @@ void updateSelfIntroduction() { .thenReturn(Optional.of(selfIntroduction)); // When - selfIntroductionService.update(new SelfIntroductionWriteRequest(title, updatedContent), memberId, - selfIntroductionId); + selfIntroductionService.update(new SelfIntroductionWriteRequest(title, updatedContent, newImageUrl), + memberId, selfIntroductionId); // When Assertions.assertThat(selfIntroduction.getMemberId()).isEqualTo(memberId); Assertions.assertThat(selfIntroduction.getContent()).isEqualTo(updatedContent); Assertions.assertThat(selfIntroduction.getTitle()).isEqualTo(title); + Assertions.assertThat(selfIntroduction.getImageUrl()).isEqualTo(newImageUrl); } } @@ -215,7 +245,8 @@ void throwExceptionWhenMemberIdFromSelfIntroductionIsNotEqualMemberId() { SelfIntroduction selfIntroduction = SelfIntroduction.write( 1L, "셀프 소개 제목", - "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)" + "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)", + null ); Mockito.when(memberCommandRepository.findById(memberId)) @@ -238,7 +269,8 @@ void deleteSelfIntroduction() { SelfIntroduction selfIntroduction = SelfIntroduction.write( memberId, "셀프 소개 제목", - "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)" + "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)", + null ); Mockito.when(memberCommandRepository.findById(memberId)) @@ -279,7 +311,8 @@ void changeToOpen() { SelfIntroduction selfIntroduction = SelfIntroduction.write( memberId, "셀프 소개 제목", - "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)" + "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)", + null ); selfIntroduction.close(); @@ -302,7 +335,8 @@ void changeToClose() { SelfIntroduction selfIntroduction = SelfIntroduction.write( memberId, "셀프 소개 제목", - "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)" + "셀프 소개 내용입니다. 최소 내용이 30자 이상입니다~!!! (30자 이상)", + null ); Mockito.when(selfIntroductionCommandRepository.findById(selfIntroductionId)) @@ -315,4 +349,41 @@ void changeToClose() { Assertions.assertThat(selfIntroduction.isOpened()).isFalse(); } } + + @Nested + @DisplayName("셀프 소개 이미지 presigned URL 발급") + class GetPresignedUrl { + + @Test + @DisplayName("존재하지 않는 멤버의 ID인 경우, 예외 발생") + void throwExceptionWhenMemberNotFound() { + // Given + Long memberId = 1L; + Mockito.when(memberCommandRepository.findById(memberId)).thenReturn(Optional.empty()); + + // When & Then + Assertions.assertThatThrownBy(() -> selfIntroductionService.getPresignedUrl("a.jpg", memberId)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + @DisplayName("self-introduction prefix로 presigned URL을 요청한다.") + void getPresignedUrlWithPrefix() { + // Given + Long memberId = 1L; + String fileName = "a.jpg"; + PresignedUrlResponse expected = new PresignedUrlResponse("https://put", "https://obj"); + Mockito.when(memberCommandRepository.findById(memberId)) + .thenReturn(Optional.of(Mockito.mock(Member.class))); + Mockito.when(s3Uploader.getPresignedUrl(eq(fileName), eq(memberId), eq("self-introduction"))) + .thenReturn(expected); + + // When + PresignedUrlResponse actual = selfIntroductionService.getPresignedUrl(fileName, memberId); + + // Then + Assertions.assertThat(actual).isEqualTo(expected); + verify(s3Uploader).getPresignedUrl(any(), any(), eq("self-introduction")); + } + } } diff --git a/src/test/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroductionTest.java b/src/test/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroductionTest.java index 79d9b5cb7..ffb3b703a 100644 --- a/src/test/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroductionTest.java +++ b/src/test/java/deepple/deepple/community/command/domain/selfintroduction/SelfIntroductionTest.java @@ -2,6 +2,7 @@ import deepple.deepple.community.command.domain.selfintroduction.exception.InvalidSelfIntroductionContentException; import deepple.deepple.community.command.domain.selfintroduction.exception.InvalidSelfIntroductionTitleException; +import deepple.deepple.member.command.domain.profileImage.exception.InvalidImageUrlException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,12 +21,29 @@ void writeSelfIntroduction() { String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; // When - SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content); + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content, null); // Then assertThat(selfIntroduction).isNotNull(); assertThat(selfIntroduction.getMemberId()).isEqualTo(memberId); assertThat(selfIntroduction.getContent()).isEqualTo(content); + assertThat(selfIntroduction.getImageUrl()).isNull(); + } + + @Test + @DisplayName("이미지 URL이 포함된 셀프 소개를 생성한다.") + void writeSelfIntroductionWithImage() { + // Given + Long memberId = 1L; + String title = "셀프 소개 제목"; + String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; + String imageUrl = "https://example.com/image.jpg"; + + // When + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content, imageUrl); + + // Then + assertThat(selfIntroduction.getImageUrl()).isEqualTo(imageUrl); } @Nested @@ -40,7 +58,7 @@ void throwExceptionWhenMemberIdIsNull() { String content = "셀프 소개 내용."; // When & Then - assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content)) + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, null)) .isInstanceOf(NullPointerException.class); } @@ -53,7 +71,7 @@ void throwsExceptionWhenTitleIsNull() { String content = "셀프 소개 내용."; // When & Then - assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content)) + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, null)) .isInstanceOf(NullPointerException.class); } @@ -66,7 +84,7 @@ void throwsExceptionWhenTitleIsBlank() { String content = "셀프 소개 내용."; // When & Then - assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content)) + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, null)) .isInstanceOf(InvalidSelfIntroductionTitleException.class); } @@ -79,7 +97,7 @@ void throwExceptionWhenContentIsNull() { String content = null; // When & Then - assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content)) + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, null)) .isInstanceOf(NullPointerException.class); } @@ -92,9 +110,23 @@ void throwExceptionWhenContentIsLessThen30() { String content = "30자 이하."; // When & Then - assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content)) + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, null)) .isInstanceOf(InvalidSelfIntroductionContentException.class); } + + @Test + @DisplayName("이미지 URL이 빈 문자열이면, 예외 발생") + void throwExceptionWhenImageUrlIsBlank() { + // Given + Long memberId = 1L; + String title = "셀프 소개 제목"; + String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; + String imageUrl = ""; + + // When & Then + assertThatThrownBy(() -> SelfIntroduction.write(memberId, title, content, imageUrl)) + .isInstanceOf(InvalidImageUrlException.class); + } } @DisplayName("셀프 소개의 공개 여부 변경 테스트") @@ -107,7 +139,7 @@ void updateClose() { Long memberId = 1L; String title = "셀프 소개 제목"; String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; - SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content); + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content, null); // When selfIntroduction.close(); @@ -123,7 +155,7 @@ void updateOpen() { Long memberId = 1L; String title = "셀프 소개 제목"; String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; - SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content); + SelfIntroduction selfIntroduction = SelfIntroduction.write(memberId, title, content, null); // When selfIntroduction.open(); @@ -131,5 +163,51 @@ void updateOpen() { // Then assertThat(selfIntroduction.isOpened()).isTrue(); } + + @Test + @DisplayName("셀프 소개에 이미지를 추가한다.") + void addImage() { + // Given + String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; + SelfIntroduction selfIntroduction = SelfIntroduction.write(1L, "title", content, null); + String newImageUrl = "https://example.com/image.jpg"; + + // When + selfIntroduction.update("title", content, newImageUrl); + + // Then + assertThat(selfIntroduction.getImageUrl()).isEqualTo(newImageUrl); + } + + @Test + @DisplayName("셀프 소개의 이미지를 제거한다.") + void removeImage() { + // Given + String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; + SelfIntroduction selfIntroduction = SelfIntroduction.write(1L, "title", content, + "https://example.com/image.jpg"); + + // When + selfIntroduction.update("title", content, null); + + // Then + assertThat(selfIntroduction.getImageUrl()).isNull(); + } + + @Test + @DisplayName("셀프 소개의 이미지를 다른 URL로 교체한다.") + void replaceImage() { + // Given + String content = "셀프 소개 내용이 공백 포함하여 최소 30자 이상이어야 합니다."; + SelfIntroduction selfIntroduction = SelfIntroduction.write(1L, "title", content, + "https://example.com/old.jpg"); + String newImageUrl = "https://example.com/new.jpg"; + + // When + selfIntroduction.update("title", content, newImageUrl); + + // Then + assertThat(selfIntroduction.getImageUrl()).isEqualTo(newImageUrl); + } } } diff --git a/src/test/java/deepple/deepple/community/query/AdminSelfIntroductionQueryRepositoryTest.java b/src/test/java/deepple/deepple/community/query/AdminSelfIntroductionQueryRepositoryTest.java index ca1c15a77..4c299fb8a 100644 --- a/src/test/java/deepple/deepple/community/query/AdminSelfIntroductionQueryRepositoryTest.java +++ b/src/test/java/deepple/deepple/community/query/AdminSelfIntroductionQueryRepositoryTest.java @@ -81,9 +81,9 @@ void setUp() { for (int i = 0; i < 5; i++) { SelfIntroduction selfIntroductionByMan = SelfIntroduction.write(manMember.getId(), "제목" + i, - "내용은 30자 이상이어야합니다... 30자를 채우자!! " + i); + "내용은 30자 이상이어야합니다... 30자를 채우자!! " + i, null); SelfIntroduction selfIntroductionByWoman = SelfIntroduction.write(womanMember.getId(), "제목입니다." + i, - "내용입니다. 내용은 30자 이상이어야합니다... 30자를 채우자!! " + i); + "내용입니다. 내용은 30자 이상이어야합니다... 30자를 채우자!! " + i, null); if (i % 2 == 0) { selfIntroductionByMan.close(); diff --git a/src/test/java/deepple/deepple/community/query/SelfIntroductionQueryRepositoryTest.java b/src/test/java/deepple/deepple/community/query/SelfIntroductionQueryRepositoryTest.java index 97c01ebca..08c9b0e35 100644 --- a/src/test/java/deepple/deepple/community/query/SelfIntroductionQueryRepositoryTest.java +++ b/src/test/java/deepple/deepple/community/query/SelfIntroductionQueryRepositoryTest.java @@ -103,9 +103,9 @@ void setUp() { // 남자 6개, 여자 6개 for (int i = 0; i < 6; i++) { SelfIntroduction maleSelfIntroduction = SelfIntroduction.write(maleMember.getId(), "제목" + i, - "내용은 30자 이상 이어야 합니다. 30자 채우기용 30자 채우기용 " + i); + "내용은 30자 이상 이어야 합니다. 30자 채우기용 30자 채우기용 " + i, null); SelfIntroduction femaleSelfIntroduction = SelfIntroduction.write(femaleMember.getId(), "제목" + i, - "내용은 30자 이상 이어야 합니다. 30자 채우기용 30자 채우기용 " + i); + "내용은 30자 이상 이어야 합니다. 30자 채우기용 30자 채우기용 " + i, null); selfIntroductions.add(maleSelfIntroduction); selfIntroductions.add(femaleSelfIntroduction); @@ -359,7 +359,7 @@ void setUp() { // 셀프 소개 데이터 생성. selfIntroduction = SelfIntroduction.write( - targetMember.getId(), "제목", "내용은 50자를 넘어야합니다. 50자를 넘어야 합니다. 50자를 넘어야.." + targetMember.getId(), "제목", "내용은 50자를 넘어야합니다. 50자를 넘어야 합니다. 50자를 넘어야..", null ); diff --git a/src/test/java/deepple/deepple/member/command/application/profileimage/ProfileImageServiceTest.java b/src/test/java/deepple/deepple/member/command/application/profileimage/ProfileImageServiceTest.java index 830ba80f3..a3249b55b 100644 --- a/src/test/java/deepple/deepple/member/command/application/profileimage/ProfileImageServiceTest.java +++ b/src/test/java/deepple/deepple/member/command/application/profileimage/ProfileImageServiceTest.java @@ -1,10 +1,10 @@ package deepple.deepple.member.command.application.profileimage; +import deepple.deepple.common.infra.s3.S3Uploader; import deepple.deepple.member.command.application.profileImage.ProfileImageService; import deepple.deepple.member.command.application.profileImage.exception.ExceedProfileImageCountException; import deepple.deepple.member.command.application.profileImage.exception.InvalidProfileImageExtensionException; import deepple.deepple.member.command.domain.profileImage.ProfileImageCommandRepository; -import deepple.deepple.member.command.infra.profileImage.S3Uploader; import deepple.deepple.member.presentation.profileimage.dto.ProfileImageUploadRequest; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName;