Skip to content

Commit

Permalink
Merge pull request #86 from Ajou-Hertz/feature/#73-edit-profile-image
Browse files Browse the repository at this point in the history
내 프로필 사진 변경 API 구현
  • Loading branch information
Wo-ogie authored Mar 26, 2024
2 parents 9756df4 + d8ef54d commit c81bde4
Show file tree
Hide file tree
Showing 12 changed files with 378 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,11 @@ public interface FileService {
* @param storedFileNames 삭제할 파일들의 이름 목록
*/
void deleteAll(Collection<String> storedFileNames);

/**
* 파일을 삭제한다.
*
* @param storedFileName 삭제할 파일의 이름
*/
void deleteFile(String storedFileName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -117,6 +120,24 @@ public ResponseEntity<UserResponse> 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 = "연락 수단을 변경합니다.",
Expand All @@ -131,4 +152,5 @@ public UserResponse updateContactLinkV1(
updateContactLinkRequest.getContactLink());
return UserResponse.from(userUpdated);
}
}
}

4 changes: 4 additions & 0 deletions src/main/java/com/ajou/hertz/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ public static User create(
);
}

public void changeProfileImageUrl(String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
}

public void changeContactLink(String contactLink) {
this.contactLink = contactLink;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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<UserProfileImage, Long> {
Optional<UserProfileImage> findByUser_Id(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@ public class UserCommandService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final HertzProperties hertzProperties;
private final UserProfileImageCommandService userProfileImageCommandService;

/**
* 새로운 회원을 등록한다.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserProfileImage> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -281,5 +314,4 @@ private UserDto createUserDto() throws Exception {
private UserDetails createTestUser(Long userId) throws Exception {
return new UserPrincipal(createUserDto(userId));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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");
Expand Down Expand Up @@ -172,6 +182,30 @@ static Stream<Arguments> 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
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit c81bde4

Please sign in to comment.