diff --git a/src/main/java/com/ajou/hertz/common/file/service/AmazonS3FileService.java b/src/main/java/com/ajou/hertz/common/file/service/AmazonS3FileService.java index f3a0c8e..76acac8 100644 --- a/src/main/java/com/ajou/hertz/common/file/service/AmazonS3FileService.java +++ b/src/main/java/com/ajou/hertz/common/file/service/AmazonS3FileService.java @@ -130,4 +130,17 @@ private static InputStream getInputStreamFromMultipartFile(MultipartFile multipa throw new MultipartFileNotReadableException(ex); } } + + /** + * S3 bucket에서 파일을 삭제한다. + * + * @param storedFileName 삭제할 파일의 이름 (key of bucket object) + */ + @Override + public void deleteFile(String storedFileName) { + s3Client.deleteObject( + new DeleteObjectRequest(awsProperties.s3().bucketName(), storedFileName) + ); + } + } diff --git a/src/main/java/com/ajou/hertz/common/file/service/FileService.java b/src/main/java/com/ajou/hertz/common/file/service/FileService.java index c7ba27d..af75a5d 100644 --- a/src/main/java/com/ajou/hertz/common/file/service/FileService.java +++ b/src/main/java/com/ajou/hertz/common/file/service/FileService.java @@ -33,4 +33,11 @@ public interface FileService { * @param storedFileNames 삭제할 파일들의 이름 목록 */ void deleteAll(Collection storedFileNames); + + /** + * 파일을 삭제한다. + * + * @param storedFileName 삭제할 파일의 이름 + */ + void deleteFile(String storedFileName); } diff --git a/src/main/java/com/ajou/hertz/domain/user/controller/UserController.java b/src/main/java/com/ajou/hertz/domain/user/controller/UserController.java index b9d4db3..e677f9a 100644 --- a/src/main/java/com/ajou/hertz/domain/user/controller/UserController.java +++ b/src/main/java/com/ajou/hertz/domain/user/controller/UserController.java @@ -4,6 +4,7 @@ import java.net.URI; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -13,7 +14,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import com.ajou.hertz.common.auth.UserPrincipal; import com.ajou.hertz.common.validator.PhoneNumber; @@ -117,6 +120,24 @@ public ResponseEntity signUpV1( .body(UserResponse.from(userCreated)); } + @Operation( + summary = "프로필 이미지 변경", + description = "프로필 이미지를 변경합니다.", + security = @SecurityRequirement(name = "access-token") + ) + @PutMapping( + value = "/me/profile-images", + headers = API_VERSION_HEADER_NAME + "=" + 1, + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public UserResponse updateProfileImageV1( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestPart MultipartFile profileImage + ) { + UserDto userUpdated = userCommandService.updateUserProfileImage(userPrincipal.getUserId(), profileImage); + return UserResponse.from(userUpdated); + } + @Operation( summary = "연락 수단 변경", description = "연락 수단을 변경합니다.", @@ -131,4 +152,5 @@ public UserResponse updateContactLinkV1( updateContactLinkRequest.getContactLink()); return UserResponse.from(userUpdated); } -} \ No newline at end of file +} + diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index 0be60e1..e1340e5 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -101,6 +101,10 @@ public static User create( ); } + public void changeProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + public void changeContactLink(String contactLink) { this.contactLink = contactLink; } diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/UserProfileImage.java b/src/main/java/com/ajou/hertz/domain/user/entity/UserProfileImage.java index e899fa8..aacdab8 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/UserProfileImage.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/UserProfileImage.java @@ -24,7 +24,7 @@ public class UserProfileImage extends FileEntity { @Column(name = "user_profile_image_id", nullable = false) private Long id; - @JoinColumn(name = "user_id", nullable = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) @OneToOne(fetch = FetchType.LAZY) private User user; @@ -33,4 +33,14 @@ private UserProfileImage(Long id, User user, String originalName, String storedN this.id = id; this.user = user; } + + public static UserProfileImage create( + User user, + String originalName, + String storedName, + String url + ) { + return new UserProfileImage(null, user, originalName, storedName, url); + } + } diff --git a/src/main/java/com/ajou/hertz/domain/user/repository/UserProfileImageRepository.java b/src/main/java/com/ajou/hertz/domain/user/repository/UserProfileImageRepository.java new file mode 100644 index 0000000..de1bfb0 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/repository/UserProfileImageRepository.java @@ -0,0 +1,11 @@ +package com.ajou.hertz.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ajou.hertz.domain.user.entity.UserProfileImage; + +public interface UserProfileImageRepository extends JpaRepository { + Optional findByUser_Id(Long userId); +} diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java index ff64dcf..088ad6f 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; import com.ajou.hertz.common.properties.HertzProperties; @@ -29,6 +30,7 @@ public class UserCommandService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final HertzProperties hertzProperties; + private final UserProfileImageCommandService userProfileImageCommandService; /** * 새로운 회원을 등록한다. @@ -130,13 +132,28 @@ private String generateRandom16CharString() { .substring(0, 16); } + /** + * 유저의 프로필 이미지를 업데이트합니다. + * + * @param userId 유저의 ID + * @param profileImage 변경할 프로필 이미지 + * + * @return 변경된 유저 정보 + */ + public UserDto updateUserProfileImage(Long userId, MultipartFile profileImage) { + User user = userQueryService.getById(userId); + String newProfileImageUrl = userProfileImageCommandService.updateProfileImage(user, profileImage); + user.changeProfileImageUrl(newProfileImageUrl); + return UserDto.from(user); + } + /** *연락 수단을 변경합니다. * * @param userId 유저의 ID * @param contactLink 변경할 연락 수단 * - *@return 변경된 유저 정보 + * @return 변경된 유저 정보 */ public UserDto updateContactLink(Long userId, String contactLink) { User user = userQueryService.getById(userId); diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserProfileImageCommandService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserProfileImageCommandService.java new file mode 100644 index 0000000..3d46e1b --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserProfileImageCommandService.java @@ -0,0 +1,73 @@ +package com.ajou.hertz.domain.user.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.ajou.hertz.common.file.dto.FileDto; +import com.ajou.hertz.common.file.service.FileService; +import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.entity.UserProfileImage; +import com.ajou.hertz.domain.user.repository.UserProfileImageRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional +@Service +public class UserProfileImageCommandService { + + private final FileService fileService; + private final UserProfileImageRepository userProfileImageRepository; + + private static final String USER_PROFILE_IMAGE_UPLOAD_PATH = "user-profile-images/"; + + /** + * 유저의 프로필 이미지를 업데이트한다. + * + * @param user 프로필 이미지를 업데이트할 유저 + * @param newProfileImage 새로운 프로필 이미지 + * + * @return 새로운 프로필 이미지 URL + */ + public String updateProfileImage(User user, MultipartFile newProfileImage) { + deleteOldProfileImage(user.getId()); + userProfileImageRepository.flush(); + UserProfileImage newUserProfileImage = uploadNewProfileImage(user, newProfileImage); + return newUserProfileImage.getUrl(); + } + + /** + * 유저의 프로필 이미지를 삭제한다. + * + * @param userId 프로필 이미지를 삭제할 유저의 id + */ + private void deleteOldProfileImage(Long userId) { + Optional optionalOldProfileImage = userProfileImageRepository.findByUser_Id(userId); + if (optionalOldProfileImage.isPresent()) { + UserProfileImage oldProfileImage = optionalOldProfileImage.get(); + userProfileImageRepository.delete(oldProfileImage); + fileService.deleteFile(oldProfileImage.getStoredName()); + } + } + + /** + * 새로운 프로필 이미지를 업로드한다. + * @param user 프로필 이미지를 업데이트할 유저 + * @param newProfileImage 새로운 프로필 이미지 + * + * @return 새로운 프로필 이미지 entity + */ + private UserProfileImage uploadNewProfileImage(User user, MultipartFile newProfileImage) { + FileDto uploadedFile = fileService.uploadFile(newProfileImage, USER_PROFILE_IMAGE_UPLOAD_PATH); + UserProfileImage newUserProfileImage = UserProfileImage.create( + user, + uploadedFile.getOriginalName(), + uploadedFile.getStoredName(), + uploadedFile.getUrl()); + userProfileImageRepository.save(newUserProfileImage); + return newUserProfileImage; + } +} diff --git a/src/test/java/com/ajou/hertz/unit/common/file/service/AmazonS3FileServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/file/service/AmazonS3FileServiceTest.java index af4933f..a0c960f 100644 --- a/src/test/java/com/ajou/hertz/unit/common/file/service/AmazonS3FileServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/common/file/service/AmazonS3FileServiceTest.java @@ -123,6 +123,20 @@ void setUpMockProperties() { verifyEveryMocksShouldHaveNoMoreInteractions(); } + @Test + void S3에_저장된_파일의_이름이_주어지고_파일을_S3_버킷에서_삭제한다() throws Exception { + // given + String storedFileName = "stored-file-name"; + willDoNothing().given(s3Client).deleteObject(any(DeleteObjectRequest.class)); + + // when + sut.deleteFile(storedFileName); + + // then + then(s3Client).should().deleteObject(any(DeleteObjectRequest.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(s3Client).shouldHaveNoMoreInteractions(); } diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerTest.java index 6e46499..6ea3135 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerTest.java @@ -18,6 +18,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.test.context.event.annotation.BeforeTestMethod; import org.springframework.test.web.servlet.MockMvc; @@ -212,6 +213,38 @@ public void securitySetUp() throws Exception { verifyEveryMocksShouldHaveNoMoreInteractions(); } + @Test + void 주어진_id와_변경할_프로필_이미지로_프로필_이미지를_변경한다() throws Exception { + // given + long userId = 1L; + MockMultipartFile profileImage = new MockMultipartFile( + "profileImage", + "test.jpg", + "image/jpeg", + "test".getBytes() + ); + UserDetails userDetails = createTestUser(userId); + UserDto expectedResult = createUserDto(userId); + + given(userCommandService.updateUserProfileImage(userId, profileImage)).willReturn(expectedResult); + + // when & then + mvc.perform( + multipart("/api/users/me/profile-images") + .file(profileImage) + .header(API_VERSION_HEADER_NAME, 1) + .with(user(userDetails)) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.profileImageUrl").value(expectedResult.getProfileImageUrl())); + then(userCommandService).should().updateUserProfileImage(userId, profileImage); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + @Test void 주어진_연락수단을_새로운_연락수단으로_변경한다() throws Exception { // given @@ -281,5 +314,4 @@ private UserDto createUserDto() throws Exception { private UserDetails createTestUser(Long userId) throws Exception { return new UserPrincipal(createUserDto(userId)); } - } \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java index 148f3b3..83e571b 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java @@ -17,9 +17,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.event.annotation.BeforeTestMethod; +import org.springframework.web.multipart.MultipartFile; +import com.ajou.hertz.common.file.service.FileService; import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; import com.ajou.hertz.common.properties.HertzProperties; import com.ajou.hertz.domain.user.constant.Gender; @@ -33,6 +36,7 @@ import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserCommandService; +import com.ajou.hertz.domain.user.service.UserProfileImageCommandService; import com.ajou.hertz.domain.user.service.UserQueryService; import com.ajou.hertz.util.ReflectionUtils; @@ -55,6 +59,12 @@ class UserCommandServiceTest { @Mock private HertzProperties hertzProperties; + @Mock + private FileService fileService; + + @Mock + private UserProfileImageCommandService userProfileImageCommandService; + @BeforeTestMethod public void setUp() { given(hertzProperties.userDefaultProfileImageUrl()).willReturn("https://user-default-profile-image"); @@ -172,6 +182,30 @@ static Stream testDataForCreateNewUserWithKakao() throws Exception { assertThat(t).isInstanceOf(UserKakaoUidDuplicationException.class); } + @Test + void 주어진_유저_ID와_이미지_URL로_유저의_프로필_이미지를_업데이트한다() throws Exception { + // Given + Long userId = 1L; + User user = createUser(userId, "password", "kakaoUid"); + String newProfileImageUrl = "https://new-profile-image-url"; + + MultipartFile profileImage = new MockMultipartFile("file", "test.jpg", "image/jpeg", + "test image content".getBytes()); + + given(userQueryService.getById(userId)).willReturn(user); + given(userProfileImageCommandService.updateProfileImage(user, profileImage)).willReturn( + newProfileImageUrl); + + // When + UserDto result = sut.updateUserProfileImage(userId, profileImage); + + // Then + then(userQueryService).should().getById(userId); + then(userProfileImageCommandService).should().updateProfileImage(user, profileImage); + assertThat(result.getProfileImageUrl()).isEqualTo(newProfileImageUrl); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + @Test void 주어진_유저_ID와_연락_수단으로_연락_수단을_변경한다() throws Exception { // given @@ -209,6 +243,8 @@ private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(userQueryService).shouldHaveNoMoreInteractions(); then(userRepository).shouldHaveNoMoreInteractions(); then(passwordEncoder).shouldHaveNoMoreInteractions(); + then(fileService).shouldHaveNoMoreInteractions(); + then(userProfileImageCommandService).shouldHaveNoMoreInteractions(); } private static User createUser(Long id, String password, String kakaoUid, Gender gender) throws Exception { diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserProfileImageCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserProfileImageCommandServiceTest.java new file mode 100644 index 0000000..5ebf454 --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserProfileImageCommandServiceTest.java @@ -0,0 +1,135 @@ +package com.ajou.hertz.unit.domain.user.service; + +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import com.ajou.hertz.common.file.dto.FileDto; +import com.ajou.hertz.common.file.service.FileService; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.entity.UserProfileImage; +import com.ajou.hertz.domain.user.repository.UserProfileImageRepository; +import com.ajou.hertz.domain.user.service.UserProfileImageCommandService; +import com.ajou.hertz.util.ReflectionUtils; + +@DisplayName("[Unit] Service(Command) - User Profile Image") +@ExtendWith(MockitoExtension.class) +public class UserProfileImageCommandServiceTest { + @InjectMocks + private UserProfileImageCommandService sut; + + @Mock + private UserProfileImageRepository userProfileImageRepository; + + @Mock + private FileService fileService; + + @Test + void 기본_프로필_이미지인_경우_유저의_프로필_이미지를_최초로_업데이트한다() throws Exception { + // given + Long userId = 1L; + User user = createUser(userId, "password", "kakaoUid"); + + MultipartFile newProfileImage = new MockMultipartFile( + "profileImage", + "newProfile.jpg", + "image/jpeg", + "new image content".getBytes() + ); + + String uploadPath = "user-profile-images/"; + FileDto uploadedFile = createFileDto(); + given(fileService.uploadFile(newProfileImage, uploadPath)).willReturn(uploadedFile); + + // when + sut.updateProfileImage(user, newProfileImage); + + // then + then(fileService).should().uploadFile(newProfileImage, uploadPath); + then(userProfileImageRepository).should().save(any(UserProfileImage.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + @Test + void 기존에_프로필_이미지가_존재하는_경우_이미지를_삭제하고_다시_이미지를_저장한다() throws Exception { + // given + Long userId = 1L; + User user = createUser(userId, "password", "kakaoUid"); + + UserProfileImage oldProfileImage = createUserProfileImage(user, "oldOriginalName.jpg", "oldStoredName.jpg", + "https://example.com/old-image-url"); + given(userProfileImageRepository.findByUser_Id(userId)).willReturn(Optional.of(oldProfileImage)); + + MultipartFile newProfileImage = new MockMultipartFile( + "profileImage", + "newProfile.jpg", + "image/jpeg", + "new image content".getBytes() + ); + + String uploadPath = "user-profile-images/"; + FileDto uploadedFile = createFileDto(); + + given(fileService.uploadFile(newProfileImage, uploadPath)).willReturn(uploadedFile); + + // when + sut.updateProfileImage(user, newProfileImage); + + // then + then(userProfileImageRepository).should().findByUser_Id(userId); + then(userProfileImageRepository).should().delete(oldProfileImage); + then(fileService).should().deleteFile(oldProfileImage.getStoredName()); + then(fileService).should().uploadFile(newProfileImage, uploadPath); + then(userProfileImageRepository).should().save(any(UserProfileImage.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { + then(fileService).shouldHaveNoMoreInteractions(); + } + + private static User createUser(Long id, String password, String kakaoUid, Gender gender) throws Exception { + return ReflectionUtils.createUser( + id, + Set.of(RoleType.USER), + "test@test.com", + password, + kakaoUid, + "https://user-default-profile-image-url", + LocalDate.of(2024, 1, 1), + gender, + "010-1234-5678", + "https://contactLink" + ); + } + + private static User createUser(Long id, String password, String kakaoUid) throws Exception { + return createUser(id, password, kakaoUid, Gender.ETC); + } + + private static UserProfileImage createUserProfileImage(User user, String originalName, String storedName, + String url) { + return UserProfileImage.create(user, originalName, storedName, url); + } + + private FileDto createFileDto() throws Exception { + return ReflectionUtils.createFileDto( + "test.jpg", + "test-stored.jpg", + "https://example.com/user-profile-images/storedFileName.jpg"); + } + +}