From fc55693b75c604bcab9eae89e07e707f7ddf8aa8 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Thu, 9 Oct 2025 23:05:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84,=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=EB=A1=9C=EA=B7=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20pre?= =?UTF-8?q?signed=20URL=20=EB=B0=9C=EA=B8=89=20=EB=B0=8F=20=EC=BB=A8?= =?UTF-8?q?=ED=8E=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 학습로그 엔티티 image_url 필드 추가 * feat: Flyway V3__add_image_url_column_to_study_log.sql 추가 * feat: 멤버 프로필 이미지 Presigned URL 발급/confirm 기능 추가 * feat: 학습로그 이미지 Presigned URL 발급/confirm 기능 추가 * feat: ImageService에 cleanup 기능 추가 * refactor: 기존 CdnUrlResolver 클래스를 삭제하고, image.domain.util에 ImageUrlUtil 클래스 추가 * refactor: 회원 탈퇴 로직에 이미지 삭제 기능 추가 * test: 멤버 프로필 이미지 Presigned URL 발급 및 confirm 기능 통합/단위 테스트 추가 * test: 학습로그 이미지 Presigned URL 발급 및 confirm 기능 통합/단위 테스트 추가 * test: ImageService cleanup 단위 테스트 추가 --- .../global/resolver/CdnUrlBuilder.java | 15 -- .../application/service/ImageService.java | 18 +- .../image/domain/util/ImageUrlUtil.java | 67 +++++++ .../dto/PresignedProfileImageInfo.java | 7 + .../application/facade/MemberFacade.java | 35 ++++ .../application/service/MemberService.java | 5 + .../studytrip/member/domain/model/Member.java | 5 +- .../controller/MemberController.java | 79 ++++++++ .../request/ConfirmProfileImageRequest.java | 8 + .../request/PresignProfileImageRequest.java | 8 + .../response/PresignProfileImageResponse.java | 13 ++ .../dto/PresignedStudyLogImageInfo.java | 8 + .../application/dto/StudyLogInfo.java | 2 + .../application/facade/StudyLogFacade.java | 29 +++ .../application/service/StudyLogService.java | 19 ++ .../domain/error/StudyLogErrorCode.java | 5 + .../studylog/domain/model/StudyLog.java | 14 ++ .../domain/policy/StudyLogPolicy.java | 11 +- .../domain/repository/StudyLogRepository.java | 3 + .../infra/jpa/StudyLogRepositoryAdapter.java | 6 + .../controller/StudyLogController.java | 79 ++++++++ .../request/ConfirmStudyLogImageRequest.java | 8 + .../request/PresignStudyLogImageRequest.java | 8 + .../response/LoadStudyLogsSliceResponse.java | 2 + .../PresignedStudyLogImageResponse.java | 13 ++ .../V3__add_image_url_column_to_study_log.sql | 3 + .../application/service/ImageServiceTest.java | 88 ++++++++- .../service/MemberServiceTest.java | 33 ++++ .../MemberControllerIntegrationTest.java | 162 +++++++++++++++++ .../service/StudyLogServiceTest.java | 91 ++++++++++ .../StudyLogControllerIntegrationTest.java | 170 ++++++++++++++++++ 31 files changed, 989 insertions(+), 25 deletions(-) delete mode 100644 src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java create mode 100644 src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java create mode 100644 src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java create mode 100644 src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java create mode 100644 src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java create mode 100644 src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java create mode 100644 src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java create mode 100644 src/main/resources/db/migration/V3__add_image_url_column_to_study_log.sql diff --git a/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java b/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java deleted file mode 100644 index 066d55e..0000000 --- a/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ject.studytrip.global.resolver; - -import com.ject.studytrip.global.config.properties.CdnProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class CdnUrlBuilder { - private final CdnProperties props; - - public String build(String key) { - return props.domain() + "/" + key; - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java index 33258ef..9595691 100644 --- a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java +++ b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java @@ -1,11 +1,13 @@ package com.ject.studytrip.image.application.service; +import com.ject.studytrip.global.config.properties.CdnProperties; import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.global.util.FilenameUtil; import com.ject.studytrip.image.application.dto.PresignedImageInfo; import com.ject.studytrip.image.domain.constants.ImageConstants; import com.ject.studytrip.image.domain.factory.ImageKeyFactory; import com.ject.studytrip.image.domain.policy.ImagePolicy; +import com.ject.studytrip.image.domain.util.ImageUrlUtil; import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; @@ -20,6 +22,8 @@ public class ImageService { private final S3ImageStorageProvider s3Provider; private final TikaImageProbeProvider tikaProvider; + private final CdnProperties cdnProps; + // Presigned URL 발급 public PresignedImageInfo presign(String keyPrefix, String id, String originFilename) { // 키 prefix 검증 @@ -57,15 +61,20 @@ public String confirm(String tmpKey) { // MIME 추출 및 판별, 검증 실패 시 이미지 삭제 validateMimeWithCleanup(tmpKey, head.contentLength()); - // 임시 -> 최종 이미지 복사 및 키 반환 + // 임시 -> 최종 이미지 복사 및 경로 반환 return moveToFinalLocation(tmpKey); } - // 업로드 취소, 업로드된 이미지 삭제 + // 업로드 취소 public void cancel(List uploadedKeys) { s3Provider.deleteByKeys(uploadedKeys); } + // 이미지 삭제 + public void cleanup(String imageUrl) { + ImageUrlUtil.extractKey(cdnProps.domain(), imageUrl).ifPresent(s3Provider::deleteByKey); + } + // 이미지 사이즈 검증, 실패 시 삭제 private void validateSizeWithCleanup(String tmpKey, long contentLength) { try { @@ -88,12 +97,13 @@ private void validateMimeWithCleanup(String tmpKey, long len) { } } - // 최종 경로에 이미지 복사 + // 최종 경로에 이미지 복사 및 반환 private String moveToFinalLocation(String tmpKey) { String finalKey = ImageKeyFactory.toFinalKey(tmpKey); ImagePolicy.validateKey(finalKey); s3Provider.copyByKey(tmpKey, finalKey); - return finalKey; + + return ImageUrlUtil.build(cdnProps.domain(), finalKey); } // 삭제 및 예외 처리 diff --git a/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java b/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java new file mode 100644 index 0000000..accbb8c --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java @@ -0,0 +1,67 @@ +package com.ject.studytrip.image.domain.util; + +import static org.springframework.util.StringUtils.hasText; + +import java.net.URI; +import java.util.Optional; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ImageUrlUtil { + // 이미지 최종 경로 생성 + public static String build(String baseUrl, String key) { + if (!hasText(baseUrl) || !hasText(key)) { + return null; + } + + String normalizedDomain = + baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + + return normalizedDomain + "/" + key.trim(); + } + + // 이미지 최종 경로에서 이미지 키 추출 + public static Optional extractKey(String baseUrl, String url) { + if (url == null || url.isBlank()) { + return Optional.empty(); + } + + if (baseUrl == null || baseUrl.isBlank()) { + return Optional.empty(); + } + + // URI 파싱 + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + + String host = uri.getHost(); + if (host == null || !host.equals(extractHostName(baseUrl))) { + return Optional.empty(); + } + + String path = uri.getPath(); + if (path == null || path.length() <= 1) { + return Optional.empty(); + } + + // 맨 앞의 '/' 제거 + String key = path.startsWith("/") ? path.substring(1) : path; + return key.isBlank() ? Optional.empty() : Optional.of(key); + } + + // 경로에서 Host 추출 + private static String extractHostName(String baseUrl) { + if (baseUrl.startsWith("https://")) { + return baseUrl.substring(8).replaceAll("/$", ""); + } + if (baseUrl.startsWith("http://")) { + return baseUrl.substring(7).replaceAll("/$", ""); + } + return baseUrl.replaceAll("/$", ""); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java b/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java new file mode 100644 index 0000000..46d6538 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.member.application.dto; + +public record PresignedProfileImageInfo(Long memberId, String tmpKey, String presignedUrl) { + public static PresignedProfileImageInfo of(Long memberId, String tmpKey, String presignedUrl) { + return new PresignedProfileImageInfo(memberId, tmpKey, presignedUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java index 7a71a35..b023e62 100644 --- a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java +++ b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java @@ -1,22 +1,31 @@ package com.ject.studytrip.member.application.facade; +import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.application.service.ImageService; import com.ject.studytrip.member.application.dto.MemberDetail; import com.ject.studytrip.member.application.dto.MemberInfo; +import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo; import com.ject.studytrip.member.application.service.MemberService; import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import com.ject.studytrip.studylog.application.service.StudyLogService; import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.application.service.TripService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor public class MemberFacade { + private static final String MEMBER_PROFILE_IMAGE_KEY_PREFIX = "members"; + private final MemberService memberService; private final TripService tripService; private final StudyLogService studyLogService; + private final ImageService imageService; public void updateNicknameAndCategoryIfPresent(Long memberId, UpdateMemberRequest request) { Member member = memberService.getActiveMemberById(memberId); @@ -28,6 +37,7 @@ public void deleteMember(Long memberId) { Member member = memberService.getActiveMemberById(memberId); memberService.deleteMember(member); + imageService.cleanup(member.getProfileImage()); } public MemberDetail getMemberDetail(Long memberId) { @@ -39,4 +49,29 @@ public MemberDetail getMemberDetail(Long memberId) { return MemberDetail.from(memberInfo, tripCount, studyLogCount); } + + @Transactional(readOnly = true) + public PresignedProfileImageInfo issuePresignedUrl( + Long memberId, PresignProfileImageRequest request) { + Member member = memberService.getActiveMemberById(memberId); + PresignedImageInfo info = + imageService.presign( + MEMBER_PROFILE_IMAGE_KEY_PREFIX, + member.getId().toString(), + request.originFilename()); + + return PresignedProfileImageInfo.of(member.getId(), info.tmpKey(), info.presignedUrl()); + } + + @Transactional + public void confirmImage(Long memberId, ConfirmProfileImageRequest request) { + Member member = memberService.getMember(memberId); + String finalKey = imageService.confirm(request.tmpKey()); + + // 기존 이미지 삭제 (이미지가 존재하지 않아도 예외발생 X) + imageService.cleanup(member.getProfileImage()); + + // 새로운 이미지 업데이트 + memberService.updateProfileImage(member, finalKey); + } } diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java index 6a69582..1e84c94 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java @@ -49,6 +49,11 @@ public void updateNicknameAndCategoryIfPresent(Member member, UpdateMemberReques member.update(request.nickname(), memberCategory); } + public void updateProfileImage(Member member, String profileImage) { + MemberPolicy.validateNotDeleted(member); + member.updateProfileImage(profileImage); + } + @Transactional public void deleteMember(Member member) { member.updateDeletedAt(); diff --git a/src/main/java/com/ject/studytrip/member/domain/model/Member.java b/src/main/java/com/ject/studytrip/member/domain/model/Member.java index cfd34bf..0364941 100644 --- a/src/main/java/com/ject/studytrip/member/domain/model/Member.java +++ b/src/main/java/com/ject/studytrip/member/domain/model/Member.java @@ -58,7 +58,6 @@ public static Member of( .build(); } - // 프로필 이미지 수정 로직 추가 예정 public void update(String nickname, MemberCategory category) { if (hasText(nickname) && !nickname.equals(this.nickname)) { // 다른 경우에만 닉네임 수정 this.nickname = nickname; @@ -68,6 +67,10 @@ public void update(String nickname, MemberCategory category) { } } + public void updateProfileImage(String profileImage) { + if (hasText(profileImage)) this.profileImage = profileImage; + } + public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java index fed4775..3599e46 100644 --- a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java +++ b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java @@ -2,11 +2,16 @@ import com.ject.studytrip.global.common.response.StandardResponse; import com.ject.studytrip.member.application.dto.MemberDetail; +import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo; import com.ject.studytrip.member.application.facade.MemberFacade; +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import com.ject.studytrip.member.presentation.dto.response.LoadMemberDetailResponse; +import com.ject.studytrip.member.presentation.dto.response.PresignProfileImageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -56,4 +61,78 @@ public ResponseEntity loadMemberDetail( result.tripCount(), result.studyLogCount()))); } + + @Operation( + summary = "멤버 프로필 이미지 업로드용 Presigned URL 발급", + description = + """ + 멤버 프로필 이미지를 S3 Storage에 업로드하기 위한 Presigned URL을 발급합니다. + + [흐름] + 1) 멤버 수정 화면에서 수정하기 버튼을 클릭합니다. + 2) 이때 만약 프로필 이미지를 변경했다면 해당 파일이름과 함께 본 API를 호출합니다.(예: abc.jpeg 형태) + 2-1) 프로필 이미지를 변경하지 않았으면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리를 수정했을 경우) + 3) 서버는 업로드에 사용할 Presigned PUT URL과 임시 키(tmpKey)를 반환합니다. + 4) 클라이언트는 반환된 Presigned URL로 이미지를 S3에 업로드합니다. + 5) 업로드가 정상 완료되면 즉시 프로필 이미지 Confirm API를 호출하여 이미지를 검증 및 확정하고 멤버 프로필에 적용합니다. + 6) 이후 다른 수정 사항이 있다면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리도 수정했을 경우) + + [주의] + - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. + - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다. + """) + @PostMapping("/profile-images/presigned") + public ResponseEntity presigned( + @AuthenticationPrincipal String memberId, + @RequestBody @Valid PresignProfileImageRequest request) { + PresignedProfileImageInfo info = + memberFacade.issuePresignedUrl(Long.valueOf(memberId), request); + return ResponseEntity.ok() + .body( + StandardResponse.success( + HttpStatus.OK.value(), + PresignProfileImageResponse.of( + info.memberId(), info.tmpKey(), info.presignedUrl()))); + } + + @Operation( + summary = "업로드된 멤버 프로필 이미지 검증/확정", + description = + """ + Presigned URL을 통해 S3에 업로드된 프로필 이미지를 서버에서 검증하고 확정(Confirm)합니다. + + [흐름] + 1) 클라이언트는 발급받은 Presigned PUT URL로 이미지를 업로드합니다. + 2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다. + 임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다. + (예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/members/1/abc.jpg -> tmp/members/1/abc.jpg) + + 3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 회원 프로필 이미지 정보를 갱신합니다. + 만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만, + 유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야합니다. + + S3 Storage 기술 자체 예외 예시 + { + "status": 502 (BAD_GATEWAY), + "message": "Storage 서버 에러가 발생했습니다." + } + + 이미지 도메인 정책 위반 예외 예시 + { + "status": 400 (BAD_REQUEST), + "message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등 + } + + [주의] + - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. + - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. + - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. + """) + @PostMapping("/profile-images/confirm") + public ResponseEntity confirm( + @AuthenticationPrincipal String memberId, + @RequestBody @Valid ConfirmProfileImageRequest request) { + memberFacade.confirmImage(Long.valueOf(memberId), request); + return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java new file mode 100644 index 0000000..0a1f8b8 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.member.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record ConfirmProfileImageRequest( + @Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") + String tmpKey) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java new file mode 100644 index 0000000..48b4d62 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.member.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record PresignProfileImageRequest( + @Schema(description = "업로드할 원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") + String originFilename) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java b/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java new file mode 100644 index 0000000..8e8dc04 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.member.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignProfileImageResponse( + @Schema(description = "멤버 ID") Long memberId, + @Schema(description = "멤버 프로필 이미지 임시키") String tmpKey, + @Schema(description = "멤버 프로필 이미지 업로드용 Presigned URL") String presignedUrl) { + public static PresignProfileImageResponse of( + Long memberId, String tmpKey, String presignedUrl) { + return new PresignProfileImageResponse(memberId, tmpKey, presignedUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java new file mode 100644 index 0000000..9a6f728 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/PresignedStudyLogImageInfo.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.application.dto; + +public record PresignedStudyLogImageInfo(Long studyLogId, String tmpKey, String presignedUrl) { + public static PresignedStudyLogImageInfo of( + Long studyLogId, String tmpKey, String presignedUrl) { + return new PresignedStudyLogImageInfo(studyLogId, tmpKey, presignedUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java index afde52a..5ae81d2 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java +++ b/src/main/java/com/ject/studytrip/studylog/application/dto/StudyLogInfo.java @@ -7,6 +7,7 @@ public record StudyLogInfo( Long studyLogId, String title, String content, + String imageUrl, String createdAt, String updatedAt, String deletedAt) { @@ -15,6 +16,7 @@ public static StudyLogInfo from(StudyLog studyLog) { studyLog.getId(), studyLog.getTitle(), studyLog.getContent(), + studyLog.getImageUrl(), DateUtil.formatDateTime(studyLog.getCreatedAt()), DateUtil.formatDateTime(studyLog.getUpdatedAt()), DateUtil.formatDateTime(studyLog.getDeletedAt())); diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java index 2a637e8..e789e34 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java +++ b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java @@ -1,16 +1,21 @@ package com.ject.studytrip.studylog.application.facade; +import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.application.service.ImageService; import com.ject.studytrip.mission.application.service.DailyMissionService; import com.ject.studytrip.mission.application.service.MissionService; import com.ject.studytrip.mission.domain.model.DailyMission; import com.ject.studytrip.pomodoro.application.service.PomodoroService; +import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo; import com.ject.studytrip.studylog.application.dto.StudyLogDetail; import com.ject.studytrip.studylog.application.dto.StudyLogInfo; import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionService; import com.ject.studytrip.studylog.application.service.StudyLogService; import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; import com.ject.studytrip.trip.application.service.DailyGoalService; import com.ject.studytrip.trip.application.service.TripService; import com.ject.studytrip.trip.domain.model.DailyGoal; @@ -25,6 +30,8 @@ @Service @RequiredArgsConstructor public class StudyLogFacade { + private static final String STUDY_LOG_IMAGE_KEY_PREFIX = "study-logs"; + private final TripService tripService; private final MissionService missionService; private final DailyGoalService dailyGoalService; @@ -32,6 +39,7 @@ public class StudyLogFacade { private final PomodoroService pomodoroService; private final StudyLogService studyLogService; private final StudyLogDailyMissionService studyLogDailyMissionService; + private final ImageService imageService; @Transactional public StudyLogInfo createStudyLog( @@ -80,6 +88,27 @@ public Slice getStudyLogsByTrip( return buildStudyLogDetailsSlice(studyLogSlice); } + @Transactional(readOnly = true) + public PresignedStudyLogImageInfo issuePresignedUrl( + Long studyLogId, PresignStudyLogImageRequest request) { + StudyLog studyLog = studyLogService.getValidStudyLogById(studyLogId); + PresignedImageInfo info = + imageService.presign( + STUDY_LOG_IMAGE_KEY_PREFIX, + studyLog.getId().toString(), + request.originFilename()); + + return PresignedStudyLogImageInfo.of(studyLog.getId(), info.tmpKey(), info.presignedUrl()); + } + + @Transactional + public void confirmImage(Long studyLogId, ConfirmStudyLogImageRequest request) { + StudyLog studyLog = studyLogService.getValidStudyLogById(studyLogId); + String imageUrl = imageService.confirm(request.tmpKey()); + + studyLogService.updateImageUrl(studyLog, imageUrl); + } + private Slice buildStudyLogDetailsSlice(Slice studyLogSlice) { List studyLogIds = studyLogSlice.getContent().stream().map(StudyLog::getId).toList(); diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java index 60b0032..91ba27a 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java @@ -1,8 +1,11 @@ package com.ject.studytrip.studylog.application.service; +import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; import com.ject.studytrip.studylog.domain.factory.StudyLogFactory; import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.studylog.domain.policy.StudyLogPolicy; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; import com.ject.studytrip.trip.domain.model.DailyGoal; @@ -48,4 +51,20 @@ public long hardDeleteStudyLogsOwnedByDeletedMember() { public long hardDeleteStudyLogsOwnedByDeletedDailyGoal() { return studyLogQueryRepository.deleteAllByDeletedDailyGoalOwner(); } + + public StudyLog getValidStudyLogById(Long studyLogId) { + StudyLog studyLog = + studyLogRepository + .findById(studyLogId) + .orElseThrow( + () -> new CustomException(StudyLogErrorCode.STUDY_LOG_NOT_FOUND)); + + StudyLogPolicy.validateNotDeleted(studyLog); + return studyLog; + } + + public void updateImageUrl(StudyLog studyLog, String imageUrl) { + StudyLogPolicy.validateNotDeleted(studyLog); + studyLog.updateImageUrl(imageUrl); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java b/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java index 784ae34..1339430 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/error/StudyLogErrorCode.java @@ -6,6 +6,11 @@ @RequiredArgsConstructor public enum StudyLogErrorCode implements ErrorCode { + // 400 + STUDY_LOG_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 학습 로그입니다."), + + // 404 + STUDY_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "학습 로그를 찾을 수 없습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java index 75b6833..27174b9 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java @@ -1,9 +1,12 @@ package com.ject.studytrip.studylog.domain.model; +import static org.flywaydb.core.internal.util.StringUtils.hasText; + import com.ject.studytrip.global.common.entity.BaseTimeEntity; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.trip.domain.model.DailyGoal; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -31,12 +34,23 @@ public class StudyLog extends BaseTimeEntity { @Column(nullable = false) private String content; + private String imageUrl; + public static StudyLog of(Member member, DailyGoal dailyGoal, String content) { return StudyLog.builder() .member(member) .dailyGoal(dailyGoal) .title(dailyGoal.getTitle()) .content(content) + .imageUrl(null) .build(); } + + public void updateImageUrl(String imageUrl) { + if (hasText(imageUrl)) this.imageUrl = imageUrl; + } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java b/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java index c014e90..06f7a47 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/policy/StudyLogPolicy.java @@ -1,7 +1,16 @@ package com.ject.studytrip.studylog.domain.policy; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; +import com.ject.studytrip.studylog.domain.model.StudyLog; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public class StudyLogPolicy {} +public class StudyLogPolicy { + public static void validateNotDeleted(StudyLog studyLog) { + if (studyLog.getDeletedAt() != null) { + throw new CustomException(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED); + } + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java index 5bfd95b..7938e93 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogRepository.java @@ -1,8 +1,11 @@ package com.ject.studytrip.studylog.domain.repository; import com.ject.studytrip.studylog.domain.model.StudyLog; +import java.util.Optional; public interface StudyLogRepository { StudyLog save(StudyLog studyLog); + + Optional findById(Long studyLogId); } diff --git a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java index 9bdbc04..a643a33 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/jpa/StudyLogRepositoryAdapter.java @@ -2,6 +2,7 @@ import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -14,4 +15,9 @@ public class StudyLogRepositoryAdapter implements StudyLogRepository { public StudyLog save(StudyLog studyLog) { return studyLogJpaRepository.save(studyLog); } + + @Override + public Optional findById(Long studyLogId) { + return studyLogJpaRepository.findById(studyLogId); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java index ac75bc7..ec84143 100644 --- a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java +++ b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java @@ -1,12 +1,16 @@ package com.ject.studytrip.studylog.presentation.controller; import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo; import com.ject.studytrip.studylog.application.dto.StudyLogDetail; import com.ject.studytrip.studylog.application.dto.StudyLogInfo; import com.ject.studytrip.studylog.application.facade.StudyLogFacade; +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; import com.ject.studytrip.studylog.presentation.dto.response.CreateStudyLogResponse; import com.ject.studytrip.studylog.presentation.dto.response.LoadStudyLogsSliceResponse; +import com.ject.studytrip.studylog.presentation.dto.response.PresignedStudyLogImageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -61,4 +65,79 @@ public ResponseEntity loadStudyLogsByTrip( StandardResponse.success( HttpStatus.OK.value(), LoadStudyLogsSliceResponse.of(result))); } + + @Operation( + summary = "학습 로그 이미지 업로드용 Presigned URL 발급", + description = + """ + 학습 로그 이미지를 S3에 업로드하기 위한 Presigned URL을 발급합니다. + + [흐름] + 1) 먼저 학습 로그 생성 API를 호출해 StudyLogId를 응답받습니다. + 2) 사용자가 이미지를 첨부했을 경우, 생성 시 받은 StudyLogId를 PathVariable로 전달하여 + 업로드용 파일명 정보를 함께 Presigned URL 발급 API를 요청합니다. + 서버는 업로드에 사용할 Presigned PUT URL과 임시키(tmpKey)를 반환합니다. + 3) 반환받은 Presigned URL로 PUT 요청을 통해 이미지를 S3에 업로드합니다. + 4) 업로드가 정상적으로 완료되면 바로 학습 로그 이미지 Confirm API를 호출합니다. + 이때 Presigned URL 발급 API에서 반환받은 임시키(tmpKey)를 함께 요청합니다. + 서버는 업로드된 이미지를 검증(크키/MIME)하고 확정합니다. + + [주의] + - 학습로그 이미지 Presigned URL 발급 요청 API는 StudyLogId가 필요하기 때문에 필수로 본 API를 호출하기 전 학습 로그를 먼저 생성해야합니다. + - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다. + - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. + """) + @PostMapping("/api/study-logs/{studyLogId}/images/presigned") + public ResponseEntity presigned( + @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") Long studyLogId, + @RequestBody @Valid PresignStudyLogImageRequest request) { + PresignedStudyLogImageInfo info = studyLogFacade.issuePresignedUrl(studyLogId, request); + return ResponseEntity.ok() + .body( + StandardResponse.success( + HttpStatus.OK.value(), + PresignedStudyLogImageResponse.of( + info.studyLogId(), info.tmpKey(), info.presignedUrl()))); + } + + @Operation( + summary = "업로드된 학습 로그 이미지 검증/확정", + description = + """ + Presigned URL을 통해 S3에 업로드된 학습 로그 이미지를 서버에서 검증하고 확정(Confirm)합니다. + + [흐름] + 1) 클라이언트는 발급받은 URL로 이미지를 업로드합니다. + 2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다. + 임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다. + (예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/study-logs/1/abc.jpg -> tmp/study-logs/1/abc.jpg) + + 3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 학습 로그 이미지 정보를 갱신합니다. + 만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만, + 유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야합니다. + + S3 Storage 기술 자체 예외 예시 + { + "status": 502 (BAD_GATEWAY), + "message": "Storage 서버 에러가 발생했습니다." + } + + 이미지 도메인 정책 위반 예외 예시 + { + "status": 400 (BAD_REQUEST), + "message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등 + } + + [주의] + - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. + - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. + - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. + """) + @PostMapping("/api/study-logs/{studyLogId}/images/confirm") + public ResponseEntity confirm( + @PathVariable @NotNull(message = "학습 로그 ID는 필수 요청 파라미터입니다.") Long studyLogId, + @RequestBody @Valid ConfirmStudyLogImageRequest request) { + studyLogFacade.confirmImage(studyLogId, request); + return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java new file mode 100644 index 0000000..c0d3a02 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/ConfirmStudyLogImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record ConfirmStudyLogImageRequest( + @Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") + String tmpKey) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java new file mode 100644 index 0000000..4d6e701 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/request/PresignStudyLogImageRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.studylog.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +public record PresignStudyLogImageRequest( + @Schema(description = "원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") + String originFilename) {} diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java index 6c5ab12..e1da4f8 100644 --- a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/LoadStudyLogsSliceResponse.java @@ -29,6 +29,7 @@ private record StudyLogResponse( List dailyMissions, @Schema(description = "학습 로그 제목") String title, @Schema(description = "학습 로그 내용") String content, + @Schema(description = "학습 로그 이미지 URL") String imageUrl, @Schema(description = "학습 로그 생성날짜") String createdAt) { private static StudyLogResponse of( StudyLogInfo studyLogInfo, @@ -46,6 +47,7 @@ private static StudyLogResponse of( .toList(), studyLogInfo.title(), studyLogInfo.content(), + studyLogInfo.imageUrl(), studyLogInfo.createdAt()); } diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java new file mode 100644 index 0000000..fb35ce8 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/presentation/dto/response/PresignedStudyLogImageResponse.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.studylog.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PresignedStudyLogImageResponse( + @Schema(description = "학습 로그 ID") Long studyLogId, + @Schema(description = "학습 로그 이미지 임시키") String tmpKey, + @Schema(description = "학습 로그 이미지 업로드용 Presigned URL") String presignedUrl) { + public static PresignedStudyLogImageResponse of( + Long studyLogId, String tmpKey, String presignedUrl) { + return new PresignedStudyLogImageResponse(studyLogId, tmpKey, presignedUrl); + } +} diff --git a/src/main/resources/db/migration/V3__add_image_url_column_to_study_log.sql b/src/main/resources/db/migration/V3__add_image_url_column_to_study_log.sql new file mode 100644 index 0000000..a28a7e3 --- /dev/null +++ b/src/main/resources/db/migration/V3__add_image_url_column_to_study_log.sql @@ -0,0 +1,3 @@ +-- study_log 테이블 : image_url 필드 추가 +ALTER TABLE study_log +ADD COLUMN image_url VARCHAR(255); \ No newline at end of file diff --git a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java index bf5cabd..997e3a5 100644 --- a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java +++ b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.config.properties.CdnProperties; import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.image.application.dto.PresignedImageInfo; import com.ject.studytrip.image.domain.error.ImageErrorCode; @@ -31,6 +32,7 @@ class ImageServiceTest extends BaseUnitTest { @InjectMocks private ImageService imageService; @Mock private S3ImageStorageProvider s3Provider; @Mock private TikaImageProbeProvider tikaProvider; + @Mock private CdnProperties cdnProperties; @Nested @DisplayName("presign 메서드는") @@ -90,20 +92,23 @@ void shouldThrowExceptionWhenExtensionIsInvalid() { class Confirm { @Test - @DisplayName("유효한 이미지를 검증하고 최종 키를 반환한다") - void shouldConfirmValidImageAndReturnFinalKey() { + @DisplayName("유효한 이미지를 검증하고 CDN URL을 반환한다") + void shouldConfirmValidImageAndReturnCdnUrl() { // given ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(); given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); given(s3Provider.readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH)) .willReturn(JPEG_HEADER_BYTES); given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(VALID_MIME); + given(cdnProperties.domain()).willReturn("test-cdn.cloudfront.net"); // when String result = imageService.confirm(TMP_KEY); // then - assertThat(result).isEqualTo(FINAL_KEY); + assertThat(result).isNotNull(); + assertThat(result).startsWith("test-cdn.cloudfront.net/"); + assertThat(result).contains(FINAL_KEY); verify(s3Provider).getHeadByKey(TMP_KEY); verify(s3Provider).readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH); verify(tikaProvider).detectMime(JPEG_HEADER_BYTES); @@ -224,12 +229,15 @@ void shouldReadOnlyActualSizeWhenSmallerThanProbeBytes() { given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); given(s3Provider.readPrefix(TMP_KEY, (int) smallSize)).willReturn(smallImageBytes); given(tikaProvider.detectMime(smallImageBytes)).willReturn(VALID_MIME); + given(cdnProperties.domain()).willReturn("test-cdn.cloudfront.net"); // when String result = imageService.confirm(TMP_KEY); // then - assertThat(result).isEqualTo(FINAL_KEY); + assertThat(result).isNotNull(); + assertThat(result).startsWith("test-cdn.cloudfront.net/"); + assertThat(result).contains(FINAL_KEY); verify(s3Provider).readPrefix(TMP_KEY, (int) smallSize); } } @@ -264,4 +272,76 @@ void shouldHandleEmptyList() { verify(s3Provider).deleteByKeys(emptyKeys); } } + + @Nested + @DisplayName("cleanup 메서드는") + class Cleanup { + private static final String IMAGE_BASE_URL = "https://test-cdn.cloudfront.net"; + private static final String EXTRACTED_KEY = "members/1/test.jpg"; + + @Test + @DisplayName("유효한 CDN URL에서 키를 추출하고 이미지를 삭제한다") + void shouldExtractKeyAndDeleteImage() { + // given + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + + // when + imageService.cleanup(IMAGE_BASE_URL + "/" + EXTRACTED_KEY); + + // then + verify(s3Provider).deleteByKey(EXTRACTED_KEY); + } + + @Test + @DisplayName("null URL이면 삭제하지 않는다") + void shouldNotDeleteWhenUrlIsNull() { + // given + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + + // when + imageService.cleanup(null); + + // then + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("빈 URL이면 삭제하지 않는다") + void shouldNotDeleteWhenUrlIsEmpty() { + // given + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + + // when + imageService.cleanup(""); + + // then + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("잘못된 CDN 도메인이면 삭제하지 않는다") + void shouldNotDeleteWhenCdnDomainMismatch() { + // given + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + + // when + imageService.cleanup("https://wrong-cdn.com/members/1/test.jpg"); + + // then + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("유효하지 않은 URL 형식이면 삭제하지 않는다") + void shouldNotDeleteWhenUrlFormatIsInvalid() { + // given + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); + + // when + imageService.cleanup("invalid-url"); + + // then + verify(s3Provider, never()).deleteByKey(anyString()); + } + } } diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java index a3b37d9..126516e 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java @@ -364,6 +364,39 @@ void shouldReturnRoleNameWhenMemberIdIsValid() { } } + @Nested + @DisplayName("updateProfileImage 메서드는") + class UpdateProfileImage { + private static final String NEW_PROFILE_IMAGE = + "https://cdn.example.com/members/1/profile.jpg"; + + @Test + @DisplayName("삭제된 멤버의 프로필 이미지를 수정하면 예외가 발생한다") + void shouldThrowExceptionWhenMemberIsDeleted() { + // given + member.updateDeletedAt(); + + // when & then + assertThatThrownBy(() -> memberService.updateProfileImage(member, NEW_PROFILE_IMAGE)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("유효한 멤버의 프로필 이미지를 수정한다") + void shouldUpdateProfileImageWhenMemberIsValid() { + // given + String oldProfileImage = member.getProfileImage(); + + // when + memberService.updateProfileImage(member, NEW_PROFILE_IMAGE); + + // then + assertThat(member.getProfileImage()).isEqualTo(NEW_PROFILE_IMAGE); + assertThat(member.getProfileImage()).isNotEqualTo(oldProfileImage); + } + } + @Nested @DisplayName("hardDeleteMembers 메서드는") class HardDeleteMembers { diff --git a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java index 4681317..6773551 100644 --- a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java @@ -1,8 +1,12 @@ package com.ject.studytrip.member.presentation.controller; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -10,13 +14,18 @@ import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.auth.fixture.TokenFixture; import com.ject.studytrip.auth.helper.TokenTestHelper; +import com.ject.studytrip.image.domain.error.ImageErrorCode; +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.member.domain.error.MemberErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture; import com.ject.studytrip.member.helper.MemberTestHelper; +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import com.ject.studytrip.trip.domain.model.TripCategory; import com.ject.studytrip.trip.helper.TripTestHelper; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -25,6 +34,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; @DisplayName("MemberController 통합 테스트") @@ -35,6 +45,8 @@ class MemberControllerIntegrationTest extends BaseIntegrationTest { @Autowired private TokenTestHelper tokenTestHelper; @Autowired private TripTestHelper tripTestHelper; + @MockitoBean S3ImageStorageProvider s3ImageStorageProvider; + private Member member; private String accessToken; @@ -297,4 +309,154 @@ void shouldReturnMemberDetailWhenMemberIdIsValid() throws Exception { .andExpect(jsonPath("$.data.studyLogCount").value(0)); } } + + @Nested + @DisplayName("프로필 이미지 Presigned URL 발급 API") + class IssuePresignedUrl { + private ResultActions getResultActions( + String accessToken, PresignProfileImageRequest request) throws Exception { + return mockMvc.perform( + post(BASE_MEMBER_URL + "/profile-images/presigned") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + PresignProfileImageRequest request = new PresignProfileImageRequest("test.jpg"); + + // when + ResultActions resultActions = getResultActions("", request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("유효한 파일명으로 Presigned URL을 발급한다") + void shouldIssuePresignedUrlWhenFilenameIsValid() throws Exception { + // given + PresignProfileImageRequest request = new PresignProfileImageRequest("profile.jpg"); + given(s3ImageStorageProvider.issuePresignedUrl(anyString())) + .willReturn("https://mocked-presigned-url.com"); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty()) + .andExpect(jsonPath("$.data.tmpKey").isNotEmpty()) + .andExpect(jsonPath("$.data.tmpKey").value(Matchers.startsWith("tmp/members/"))) + .andExpect( + jsonPath("$.data.tmpKey") + .value(Matchers.containsString(member.getId().toString()))); + + // S3Provider 호출 검증 + verify(s3ImageStorageProvider).issuePresignedUrl(anyString()); + } + + @Test + @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenFilenameIsEmpty() throws Exception { + // given + PresignProfileImageRequest request = new PresignProfileImageRequest(""); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenExtensionIsInvalid() throws Exception { + // given + PresignProfileImageRequest request = new PresignProfileImageRequest("profile.txt"); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + ImageErrorCode.INVALID_IMAGE_EXTENSION + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage())); + } + } + + @Nested + @DisplayName("프로필 이미지 확정 API") + class ConfirmProfileImage { + private ResultActions getResultActions( + String accessToken, ConfirmProfileImageRequest request) throws Exception { + return mockMvc.perform( + post(BASE_MEMBER_URL + "/profile-images/confirm") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + ConfirmProfileImageRequest request = + new ConfirmProfileImageRequest("tmp/members/1/test.jpg"); + + // when + ResultActions resultActions = getResultActions("", request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenTmpKeyIsEmpty() throws Exception { + // given + ConfirmProfileImageRequest request = new ConfirmProfileImageRequest(""); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java index d75f2a7..11de3dc 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java @@ -5,8 +5,10 @@ import static org.mockito.BDDMockito.given; import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.studylog.domain.error.StudyLogErrorCode; import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; import com.ject.studytrip.studylog.domain.repository.StudyLogRepository; @@ -17,6 +19,7 @@ import com.ject.studytrip.trip.fixture.DailyGoalFixture; import com.ject.studytrip.trip.fixture.TripFixture; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -237,4 +240,92 @@ void shouldReturnCountWhenStudyLogsOwnedByDeletedDailyGoalExist() { assertThat(result).isEqualTo(5L); } } + + @Nested + @DisplayName("getValidStudyLogById 메서드는") + class GetValidStudyLogById { + + @Test + @DisplayName("존재하지 않는 학습 로그 ID로 조회하면 예외가 발생한다") + void shouldThrowExceptionWhenStudyLogNotFound() { + // given + Long invalidId = -1L; + given(studyLogRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> studyLogService.getValidStudyLogById(invalidId)) + .isInstanceOf(CustomException.class) + .hasMessage(StudyLogErrorCode.STUDY_LOG_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("삭제된 학습 로그를 조회하면 예외가 발생한다") + void shouldThrowExceptionWhenStudyLogIsDeleted() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + studyLog.updateDeletedAt(); + + given(studyLogRepository.findById(1L)).willReturn(Optional.of(studyLog)); + + // when & then + assertThatThrownBy(() -> studyLogService.getValidStudyLogById(1L)) + .isInstanceOf(CustomException.class) + .hasMessage(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("유효한 학습 로그 ID로 조회하면 학습 로그를 반환한다") + void shouldReturnStudyLogWhenIdIsValid() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + + given(studyLogRepository.findById(1L)).willReturn(Optional.of(studyLog)); + + // when + StudyLog result = studyLogService.getValidStudyLogById(1L); + + // then + assertThat(result).isEqualTo(studyLog); + assertThat(result.getDeletedAt()).isNull(); + } + } + + @Nested + @DisplayName("updateImageUrl 메서드는") + class UpdateImageUrl { + private static final String NEW_IMAGE_URL = + "https://cdn.example.com/study-logs/1/image.jpg"; + + @Test + @DisplayName("삭제된 학습 로그의 이미지 URL을 수정하면 예외가 발생한다") + void shouldThrowExceptionWhenStudyLogIsDeleted() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + studyLog.updateDeletedAt(); + + // when & then + assertThatThrownBy(() -> studyLogService.updateImageUrl(studyLog, NEW_IMAGE_URL)) + .isInstanceOf(CustomException.class) + .hasMessage(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.getMessage()); + } + + @Test + @DisplayName("유효한 학습 로그의 이미지 URL을 수정한다") + void shouldUpdateImageUrlWhenStudyLogIsValid() { + // given + DailyGoal dailyGoal = DailyGoalFixture.createDailyGoalWithId(1L, courseTrip); + StudyLog studyLog = StudyLogFixture.createStudyLogWithId(1L, member, dailyGoal); + String oldImageUrl = studyLog.getImageUrl(); + + // when + studyLogService.updateImageUrl(studyLog, NEW_IMAGE_URL); + + // then + assertThat(studyLog.getImageUrl()).isEqualTo(NEW_IMAGE_URL); + assertThat(studyLog.getImageUrl()).isNotEqualTo(oldImageUrl); + } + } } diff --git a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java index 4044593..305cef1 100644 --- a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java @@ -1,5 +1,8 @@ package com.ject.studytrip.studylog.presentation.controller; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -10,6 +13,8 @@ import com.ject.studytrip.auth.fixture.TokenFixture; import com.ject.studytrip.auth.helper.TokenTestHelper; import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.image.domain.error.ImageErrorCode; +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberRole; import com.ject.studytrip.member.helper.MemberTestHelper; @@ -29,7 +34,9 @@ import com.ject.studytrip.studylog.fixture.CreateStudyLogRequestFixture; import com.ject.studytrip.studylog.helper.StudyLogDailyMissionTestHelper; import com.ject.studytrip.studylog.helper.StudyLogTestHelper; +import com.ject.studytrip.studylog.presentation.dto.request.ConfirmStudyLogImageRequest; import com.ject.studytrip.studylog.presentation.dto.request.CreateStudyLogRequest; +import com.ject.studytrip.studylog.presentation.dto.request.PresignStudyLogImageRequest; import com.ject.studytrip.trip.domain.error.DailyGoalErrorCode; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.model.DailyGoal; @@ -45,7 +52,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; @DisplayName("StudyLogController 통합 테스트") @@ -61,6 +70,8 @@ public class StudyLogControllerIntegrationTest extends BaseIntegrationTest { @Autowired private StudyLogDailyMissionTestHelper studyLogDailyMissionTestHelper; @Autowired private PomodoroTestHelper pomodoroTestHelper; + @MockitoBean S3ImageStorageProvider s3ImageStorageProvider; + private Member member; private String token; private Trip courseTrip; @@ -700,4 +711,163 @@ void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { .value(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); } } + + @Nested + @DisplayName("학습 로그 이미지 Presigned URL 발급 API") + class IssuePresignedUrl { + private static final String PRESIGNED_URL = "/api/study-logs/%d/images/presigned"; + + private ResultActions getResultActions( + String token, Long studyLogId, PresignStudyLogImageRequest request) + throws Exception { + return mockMvc.perform( + post(String.format(PRESIGNED_URL, studyLogId)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("test.jpg"); + + // when + ResultActions resultActions = getResultActions("", studyLog.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("유효한 파일명으로 Presigned URL을 발급한다") + void shouldIssuePresignedUrlWhenFilenameIsValid() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("studylog.jpg"); + given(s3ImageStorageProvider.issuePresignedUrl(anyString())) + .willReturn("https://mocked-presigned-url.com"); + + // when + ResultActions resultActions = getResultActions(token, studyLog.getId(), request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty()) + .andExpect(jsonPath("$.data.tmpKey").isNotEmpty()) + .andExpect( + jsonPath("$.data.tmpKey").value(Matchers.startsWith("tmp/study-logs/"))) + .andExpect( + jsonPath("$.data.tmpKey") + .value(Matchers.containsString(studyLog.getId().toString()))); + + // S3Provider 호출 검증 + verify(s3ImageStorageProvider).issuePresignedUrl(anyString()); + } + + @Test + @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenFilenameIsEmpty() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + PresignStudyLogImageRequest request = new PresignStudyLogImageRequest(""); + + // when + ResultActions resultActions = getResultActions(token, studyLog.getId(), request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenExtensionIsInvalid() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + PresignStudyLogImageRequest request = new PresignStudyLogImageRequest("image.pdf"); + + // when + ResultActions resultActions = getResultActions(token, studyLog.getId(), request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + ImageErrorCode.INVALID_IMAGE_EXTENSION + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage())); + } + } + + @Nested + @DisplayName("학습 로그 이미지 확정 API") + class ConfirmImage { + private static final String CONFIRM_URL = "/api/study-logs/%d/images/confirm"; + + private ResultActions getResultActions( + String token, Long studyLogId, ConfirmStudyLogImageRequest request) + throws Exception { + return mockMvc.perform( + post(String.format(CONFIRM_URL, studyLogId)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + ConfirmStudyLogImageRequest request = + new ConfirmStudyLogImageRequest("tmp/study-logs/1/test.jpg"); + + // when + ResultActions resultActions = getResultActions("", studyLog.getId(), request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenTmpKeyIsEmpty() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + ConfirmStudyLogImageRequest request = new ConfirmStudyLogImageRequest(""); + + // when + ResultActions resultActions = getResultActions(token, studyLog.getId(), request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + } }