From e9e980509e788a35f0b27258ae15344a105adba8 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 3 May 2026 00:37:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[bug]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../paycheck/api/user/UserController.java | 11 ++ .../paycheck/domain/user/dto/UserDto.java | 14 ++- .../service/ProfileImageStorageService.java | 112 ++++++++++++++++++ .../user/service/ProfileImageUrlResolver.java | 31 +++++ .../domain/user/service/UserService.java | 35 +++++- .../paycheck/global/config/WebMvcConfig.java | 34 +++++- src/main/resources/application.properties | 4 + .../ProfileImageStorageServiceTest.java | 62 ++++++++++ .../service/ProfileImageUrlResolverTest.java | 44 +++++++ .../user/service/UserServiceSimpleTest.java | 42 ++++++- .../domain/user/service/UserServiceTest.java | 6 + 11 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java create mode 100644 src/main/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolver.java create mode 100644 src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java create mode 100644 src/test/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolverTest.java diff --git a/src/main/java/com/example/paycheck/api/user/UserController.java b/src/main/java/com/example/paycheck/api/user/UserController.java index fbdaa744..72264841 100644 --- a/src/main/java/com/example/paycheck/api/user/UserController.java +++ b/src/main/java/com/example/paycheck/api/user/UserController.java @@ -14,8 +14,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import jakarta.validation.Valid; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.bind.annotation.*; @Tag(name = "사용자", description = "사용자 정보 조회 및 관리 API") @@ -50,6 +52,15 @@ public ApiResponse updateMyInfo( return ApiResponse.success(userService.updateUser(user.getId(), request)); } + @Operation(summary = "내 프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드합니다.") + @PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadMyProfileImage( + @AuthenticationPrincipal User user, + @Parameter(description = "프로필 이미지 파일", required = true) + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(userService.updateProfileImage(user.getId(), file)); + } + @Operation(summary = "계좌 정보 수정 (근로자 전용)", description = "로그인한 근로자의 계좌 정보를 수정합니다.") @PutMapping("/me/account") public ApiResponse updateMyAccount( diff --git a/src/main/java/com/example/paycheck/domain/user/dto/UserDto.java b/src/main/java/com/example/paycheck/domain/user/dto/UserDto.java index a876fc20..a68f9cb4 100644 --- a/src/main/java/com/example/paycheck/domain/user/dto/UserDto.java +++ b/src/main/java/com/example/paycheck/domain/user/dto/UserDto.java @@ -32,24 +32,32 @@ public static class Response { private String accountNumber; public static Response from(User user) { + return from(user, user.getProfileImageUrl()); + } + + public static Response from(User user, String profileImageUrl) { return Response.builder() .id(user.getId()) .kakaoId(user.getKakaoId()) .name(user.getName()) .phone(user.getPhone()) .userType(user.getUserType()) - .profileImageUrl(user.getProfileImageUrl()) + .profileImageUrl(profileImageUrl) .build(); } public static Response from(User user, Worker worker) { + return from(user, worker, user.getProfileImageUrl()); + } + + public static Response from(User user, Worker worker, String profileImageUrl) { return Response.builder() .id(user.getId()) .kakaoId(user.getKakaoId()) .name(user.getName()) .phone(user.getPhone()) .userType(user.getUserType()) - .profileImageUrl(user.getProfileImageUrl()) + .profileImageUrl(profileImageUrl) .workerCode(worker.getWorkerCode()) .bankName(worker.getBankName()) .accountNumber(worker.getAccountNumber()) @@ -69,7 +77,7 @@ public static class UpdateRequest { @Pattern(regexp = "^01[0-9]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다. (예: 010-1234-5678)") private String phone; - @Pattern(regexp = "^(https?://.+)?$", message = "프로필 이미지는 HTTP/HTTPS URL 형식이어야 합니다.") + @Pattern(regexp = "^(https?://.+|/.+)?$", message = "프로필 이미지는 HTTP/HTTPS URL 또는 서버 이미지 경로 형식이어야 합니다.") private String profileImageUrl; } diff --git a/src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java b/src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java new file mode 100644 index 00000000..3e7ec424 --- /dev/null +++ b/src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java @@ -0,0 +1,112 @@ +package com.example.paycheck.domain.user.service; + +import com.example.paycheck.common.exception.BadRequestException; +import com.example.paycheck.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Service +public class ProfileImageStorageService { + + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp"); + + private final Path profileImageDirectory; + private final String publicUriPrefix; + + public ProfileImageStorageService( + @Value("${app.upload.profile-image-dir:uploads/profile-images}") String profileImageDirectory, + @Value("${app.upload.profile-image-uri-prefix:/uploads/profile-images/}") String publicUriPrefix) { + this.profileImageDirectory = Paths.get(profileImageDirectory).toAbsolutePath().normalize(); + this.publicUriPrefix = normalizePublicUriPrefix(publicUriPrefix); + } + + public String store(MultipartFile file) { + validate(file); + + try { + Files.createDirectories(profileImageDirectory); + + String extension = extractExtension(file.getOriginalFilename()); + String storedFileName = UUID.randomUUID() + "." + extension; + Path targetPath = profileImageDirectory.resolve(storedFileName).normalize(); + + file.transferTo(targetPath); + return publicUriPrefix + storedFileName; + } catch (IOException e) { + throw new IllegalStateException("프로필 이미지를 저장할 수 없습니다.", e); + } + } + + public void deleteIfStoredLocally(String profileImageUrl) { + if (!StringUtils.hasText(profileImageUrl) || !profileImageUrl.startsWith(publicUriPrefix)) { + return; + } + + String fileName = profileImageUrl.substring(publicUriPrefix.length()); + if (!StringUtils.hasText(fileName)) { + return; + } + + try { + Files.deleteIfExists(profileImageDirectory.resolve(fileName).normalize()); + } catch (IOException ignored) { + // 이전 프로필 이미지 정리에 실패해도 업로드 성공 흐름은 유지한다. + } + } + + public Path getProfileImageDirectory() { + return profileImageDirectory; + } + + public String getPublicUriPrefix() { + return publicUriPrefix; + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "업로드할 프로필 이미지 파일이 필요합니다."); + } + + String contentType = file.getContentType(); + if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "이미지 파일만 업로드할 수 있습니다."); + } + + extractExtension(file.getOriginalFilename()); + } + + private String extractExtension(String originalFilename) { + String extension = StringUtils.getFilenameExtension(originalFilename); + if (!StringUtils.hasText(extension)) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "지원하지 않는 이미지 형식입니다."); + } + + String normalizedExtension = extension.toLowerCase(Locale.ROOT); + if (!ALLOWED_EXTENSIONS.contains(normalizedExtension)) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "지원하지 않는 이미지 형식입니다."); + } + + return normalizedExtension; + } + + private String normalizePublicUriPrefix(String publicUriPrefix) { + String normalized = StringUtils.hasText(publicUriPrefix) ? publicUriPrefix.trim() : "/uploads/profile-images/"; + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + if (!normalized.endsWith("/")) { + normalized = normalized + "/"; + } + return normalized; + } +} diff --git a/src/main/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolver.java b/src/main/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolver.java new file mode 100644 index 00000000..f42066e2 --- /dev/null +++ b/src/main/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolver.java @@ -0,0 +1,31 @@ +package com.example.paycheck.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@Service +public class ProfileImageUrlResolver { + + public String resolve(String profileImageUrl) { + if (!StringUtils.hasText(profileImageUrl)) { + return profileImageUrl; + } + + if (profileImageUrl.startsWith("http://") || profileImageUrl.startsWith("https://")) { + return profileImageUrl; + } + + if (!profileImageUrl.startsWith("/")) { + return profileImageUrl; + } + + try { + return ServletUriComponentsBuilder.fromCurrentContextPath() + .path(profileImageUrl) + .toUriString(); + } catch (IllegalStateException e) { + return profileImageUrl; + } + } +} diff --git a/src/main/java/com/example/paycheck/domain/user/service/UserService.java b/src/main/java/com/example/paycheck/domain/user/service/UserService.java index 281fcd26..3a817e64 100644 --- a/src/main/java/com/example/paycheck/domain/user/service/UserService.java +++ b/src/main/java/com/example/paycheck/domain/user/service/UserService.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -25,6 +26,8 @@ public class UserService { private final WorkerService workerService; private final EmployerService employerService; private final UserSettingsService userSettingsService; + private final ProfileImageStorageService profileImageStorageService; + private final ProfileImageUrlResolver profileImageUrlResolver; public UserDto.Response getUserById(Long userId) { User user = userRepository.findById(userId) @@ -32,10 +35,10 @@ public UserDto.Response getUserById(Long userId) { if (user.getUserType() == UserType.WORKER) { return workerRepository.findByUserId(userId) - .map(worker -> UserDto.Response.from(user, worker)) - .orElse(UserDto.Response.from(user)); + .map(worker -> buildUserResponse(user, worker)) + .orElse(buildUserResponse(user)); } - return UserDto.Response.from(user); + return buildUserResponse(user); } @Transactional @@ -45,7 +48,23 @@ public UserDto.Response updateUser(Long userId, UserDto.UpdateRequest request) { user.updateProfile(request.getName(), request.getPhone(), request.getProfileImageUrl()); - return UserDto.Response.from(user); + return getUserById(userId); + } + + @Transactional + public UserDto.Response updateProfileImage(Long userId, MultipartFile profileImage) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + String previousProfileImageUrl = user.getProfileImageUrl(); + String storedProfileImageUrl = profileImageStorageService.store(profileImage); + user.updateProfile(null, null, storedProfileImageUrl); + + if (previousProfileImageUrl != null && !previousProfileImageUrl.equals(storedProfileImageUrl)) { + profileImageStorageService.deleteIfStoredLocally(previousProfileImageUrl); + } + + return getUserById(userId); } @Transactional @@ -76,4 +95,12 @@ public UserDto.RegisterResponse register(UserDto.RegisterRequest request) { return UserDto.RegisterResponse.from(savedUser, workerCode); } + + private UserDto.Response buildUserResponse(User user) { + return UserDto.Response.from(user, profileImageUrlResolver.resolve(user.getProfileImageUrl())); + } + + private UserDto.Response buildUserResponse(User user, Worker worker) { + return UserDto.Response.from(user, worker, profileImageUrlResolver.resolve(user.getProfileImageUrl())); + } } diff --git a/src/main/java/com/example/paycheck/global/config/WebMvcConfig.java b/src/main/java/com/example/paycheck/global/config/WebMvcConfig.java index 09bdc92b..f8db2dde 100644 --- a/src/main/java/com/example/paycheck/global/config/WebMvcConfig.java +++ b/src/main/java/com/example/paycheck/global/config/WebMvcConfig.java @@ -1,6 +1,7 @@ package com.example.paycheck.global.config; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.http.converter.ByteArrayHttpMessageConverter; @@ -13,8 +14,10 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import java.util.List; /** @@ -33,9 +36,16 @@ public class WebMvcConfig implements WebMvcConfigurer { private final ObjectMapper objectMapper; + private final String profileImageDirectory; + private final String profileImageUriPrefix; - public WebMvcConfig(ObjectMapper objectMapper) { + public WebMvcConfig( + ObjectMapper objectMapper, + @Value("${app.upload.profile-image-dir:uploads/profile-images}") String profileImageDirectory, + @Value("${app.upload.profile-image-uri-prefix:/uploads/profile-images/}") String profileImageUriPrefix) { this.objectMapper = objectMapper; + this.profileImageDirectory = profileImageDirectory; + this.profileImageUriPrefix = normalizeUriPrefix(profileImageUriPrefix); } @Override @@ -69,4 +79,26 @@ public void configureMessageConverters(List> converters) // XML 컨버터는 의도적으로 추가하지 않음 // jackson-dataformat-xml은 RestTemplate에서 공공 API XML 응답 파싱에만 사용 } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String resourceLocation = Paths.get(profileImageDirectory).toAbsolutePath().normalize().toUri().toString(); + if (!resourceLocation.endsWith("/")) { + resourceLocation = resourceLocation + "/"; + } + + registry.addResourceHandler(profileImageUriPrefix + "**") + .addResourceLocations(resourceLocation); + } + + private String normalizeUriPrefix(String uriPrefix) { + String normalized = uriPrefix; + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + if (!normalized.endsWith("/")) { + normalized = normalized + "/"; + } + return normalized; + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b3e0f865..9892b984 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -62,6 +62,10 @@ cookie.same-site=Lax # CORS Configuration (환경별 오버라이드 가능) cors.allowed-origins=http://localhost:3000,http://localhost:5173 +# Profile Image Upload Configuration +app.upload.profile-image-dir=${APP_UPLOAD_PROFILE_IMAGE_DIR:uploads/profile-images} +app.upload.profile-image-uri-prefix=${APP_UPLOAD_PROFILE_IMAGE_URI_PREFIX:/uploads/profile-images/} + # Encryption Configuration (개발용 키 - 프로덕션에서는 반드시 환경변수 사용) encryption.aes-key=IuQq5A+rPiWgKR/qdPd3TOugPl1oVW67AUMxctc81hg= diff --git a/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java new file mode 100644 index 00000000..3e15f09a --- /dev/null +++ b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java @@ -0,0 +1,62 @@ +package com.example.paycheck.domain.user.service; + +import com.example.paycheck.common.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ProfileImageStorageService 테스트") +class ProfileImageStorageServiceTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("프로필 이미지를 저장하고 공개 경로를 반환한다") + void store_Success() throws Exception { + ProfileImageStorageService storageService = new ProfileImageStorageService( + tempDir.resolve("profile-images").toString(), + "/uploads/profile-images/" + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "avatar.png", + "image/png", + "image-bytes".getBytes() + ); + + String storedPath = storageService.store(file); + + assertThat(storedPath).startsWith("/uploads/profile-images/"); + try (var storedFiles = Files.list(storageService.getProfileImageDirectory())) { + assertThat(storedFiles.count()).isEqualTo(1); + } + } + + @Test + @DisplayName("이미지가 아닌 파일은 업로드할 수 없다") + void store_Fail_InvalidContentType() { + ProfileImageStorageService storageService = new ProfileImageStorageService( + tempDir.resolve("profile-images").toString(), + "/uploads/profile-images/" + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "avatar.txt", + "text/plain", + "not-image".getBytes() + ); + + assertThatThrownBy(() -> storageService.store(file)) + .isInstanceOf(BadRequestException.class); + } +} diff --git a/src/test/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolverTest.java b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolverTest.java new file mode 100644 index 00000000..313b76d8 --- /dev/null +++ b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageUrlResolverTest.java @@ -0,0 +1,44 @@ +package com.example.paycheck.domain.user.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ProfileImageUrlResolver 테스트") +class ProfileImageUrlResolverTest { + + private final ProfileImageUrlResolver resolver = new ProfileImageUrlResolver(); + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("상대 경로 프로필 이미지를 현재 서버 기준 절대 URL로 변환한다") + void resolve_RelativePathToAbsoluteUrl() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("https"); + request.setServerName("api.example.com"); + request.setServerPort(443); + request.setContextPath(""); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + String resolved = resolver.resolve("/uploads/profile-images/avatar.png"); + + assertThat(resolved).isEqualTo("https://api.example.com/uploads/profile-images/avatar.png"); + } + + @Test + @DisplayName("이미 절대 URL이면 그대로 반환한다") + void resolve_AbsoluteUrl() { + String resolved = resolver.resolve("https://cdn.example.com/avatar.png"); + + assertThat(resolved).isEqualTo("https://cdn.example.com/avatar.png"); + } +} diff --git a/src/test/java/com/example/paycheck/domain/user/service/UserServiceSimpleTest.java b/src/test/java/com/example/paycheck/domain/user/service/UserServiceSimpleTest.java index aebe3d89..731848d1 100644 --- a/src/test/java/com/example/paycheck/domain/user/service/UserServiceSimpleTest.java +++ b/src/test/java/com/example/paycheck/domain/user/service/UserServiceSimpleTest.java @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; import java.util.Optional; @@ -42,6 +43,12 @@ class UserServiceSimpleTest { @Mock private UserSettingsService userSettingsService; + @Mock + private ProfileImageStorageService profileImageStorageService; + + @Mock + private ProfileImageUrlResolver profileImageUrlResolver; + @InjectMocks private UserService userService; @@ -72,6 +79,8 @@ void getUserById_Success() { when(userRepository.findById(1L)).thenReturn(Optional.of(testWorker)); when(workerRepository.findByUserId(1L)).thenReturn(Optional.of(mockWorker)); + when(profileImageUrlResolver.resolve("https://example.com/worker.jpg")) + .thenReturn("https://example.com/worker.jpg"); // when UserDto.Response result = userService.getUserById(1L); @@ -81,6 +90,7 @@ void getUserById_Success() { assertThat(result.getId()).isEqualTo(1L); assertThat(result.getName()).isEqualTo("근로자 테스트"); assertThat(result.getUserType()).isEqualTo(UserType.WORKER); + assertThat(result.getProfileImageUrl()).isEqualTo("https://example.com/worker.jpg"); assertThat(result.getWorkerCode()).isEqualTo("ABC123"); assertThat(result.getBankName()).isEqualTo("카카오뱅크"); assertThat(result.getAccountNumber()).isEqualTo("123456789012"); @@ -114,13 +124,17 @@ void updateUser_Success() { .build(); when(userRepository.findById(1L)).thenReturn(Optional.of(testWorker)); + when(workerRepository.findByUserId(1L)).thenReturn(Optional.of(Worker.builder().user(testWorker).build())); + when(profileImageUrlResolver.resolve("https://example.com/new_profile.jpg")) + .thenReturn("https://example.com/new_profile.jpg"); // when UserDto.Response result = userService.updateUser(1L, request); // then assertThat(result).isNotNull(); - verify(userRepository).findById(1L); + assertThat(result.getProfileImageUrl()).isEqualTo("https://example.com/new_profile.jpg"); + verify(userRepository, times(2)).findById(1L); } @Test @@ -137,4 +151,30 @@ void updateUser_NotFound() { assertThatThrownBy(() -> userService.updateUser(999L, request)) .isInstanceOf(NotFoundException.class); } + + @Test + @DisplayName("프로필 이미지 업로드 성공") + void updateProfileImage_Success() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "profile.png", + "image/png", + "image".getBytes() + ); + + when(userRepository.findById(1L)).thenReturn(Optional.of(testWorker)); + when(workerRepository.findByUserId(1L)).thenReturn(Optional.of(Worker.builder().user(testWorker).build())); + when(profileImageStorageService.store(file)).thenReturn("/uploads/profile-images/new-profile.png"); + when(profileImageUrlResolver.resolve("/uploads/profile-images/new-profile.png")) + .thenReturn("http://localhost:8080/uploads/profile-images/new-profile.png"); + + // when + UserDto.Response result = userService.updateProfileImage(1L, file); + + // then + assertThat(result.getProfileImageUrl()).isEqualTo("http://localhost:8080/uploads/profile-images/new-profile.png"); + verify(profileImageStorageService).store(file); + verify(profileImageStorageService).deleteIfStoredLocally("https://example.com/worker.jpg"); + } } diff --git a/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java index 132450ce..b362776d 100644 --- a/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java @@ -45,6 +45,12 @@ class UserServiceTest { @Mock private UserSettingsService userSettingsService; + @Mock + private ProfileImageStorageService profileImageStorageService; + + @Mock + private ProfileImageUrlResolver profileImageUrlResolver; + @InjectMocks private UserService userService; From e9267c54108c4411187304d19795ac9e8b26ec26 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 4 May 2026 21:56:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(user):=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?uploadProfileImage=20=EB=8D=B0=EB=93=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EB=A1=9C=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileImageStorageService가 uploadProfileImage(Long, MultipartFile) -> store(MultipartFile)로 리네임되었지만, UserService의 레거시 uploadProfileImage() 메서드가 삭제되지 않아 사라진 메서드를 호출하면서 컴파일 에러 발생. 변경 사항: - UserService의 데드 메서드 uploadProfileImage() 제거 (Controller는 이미 updateProfileImage() 호출) - UserServiceTest의 uploadProfileImage_Success 테스트를 updateProfileImage 흐름 검증으로 교체 - UserControllerTest의 uploadMyProfileImage_success 테스트를 신규 시그니처(updateProfileImage, Response 반환)에 맞게 수정 --- .../domain/user/service/UserService.java | 10 ------- .../paycheck/api/user/UserControllerTest.java | 12 ++++----- .../domain/user/service/UserServiceTest.java | 27 +++++++++++++------ 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/example/paycheck/domain/user/service/UserService.java b/src/main/java/com/example/paycheck/domain/user/service/UserService.java index 3cc50016..3a817e64 100644 --- a/src/main/java/com/example/paycheck/domain/user/service/UserService.java +++ b/src/main/java/com/example/paycheck/domain/user/service/UserService.java @@ -67,16 +67,6 @@ public UserDto.Response updateProfileImage(Long userId, MultipartFile profileIma return getUserById(userId); } - @Transactional - public UserDto.ProfileImageUploadResponse uploadProfileImage(Long userId, MultipartFile file) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); - - String profileImageUrl = profileImageStorageService.uploadProfileImage(userId, file); - user.updateProfileImage(profileImageUrl); - return UserDto.ProfileImageUploadResponse.from(profileImageUrl); - } - @Transactional public UserDto.RegisterResponse register(UserDto.RegisterRequest request) { // User 생성 diff --git a/src/test/java/com/example/paycheck/api/user/UserControllerTest.java b/src/test/java/com/example/paycheck/api/user/UserControllerTest.java index cccfac1e..64f15830 100644 --- a/src/test/java/com/example/paycheck/api/user/UserControllerTest.java +++ b/src/test/java/com/example/paycheck/api/user/UserControllerTest.java @@ -78,16 +78,14 @@ void uploadMyProfileImage_success() throws Exception { "image-content".getBytes() ); - given(userService.uploadProfileImage(eq(1L), any())) - .willReturn(UserDto.ProfileImageUploadResponse.from( - "https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/profile.png" - )); + String resolvedUrl = "http://localhost/uploads/profile-images/uuid-profile.png"; + + given(userService.updateProfileImage(eq(1L), any())) + .willReturn(UserDto.Response.from(testUser, resolvedUrl)); mockMvc.perform(multipart("/api/users/me/profile-image").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.profileImageUrl").value( - "https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/profile.png" - )); + .andExpect(jsonPath("$.data.profileImageUrl").value(resolvedUrl)); } } diff --git a/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java index 4f536d2d..e7bde12f 100644 --- a/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/example/paycheck/domain/user/service/UserServiceTest.java @@ -100,7 +100,7 @@ void updateUser_Fail_NotFound() { @Test @DisplayName("프로필 이미지 업로드 성공") - void uploadProfileImage_Success() { + void updateProfileImage_Success() { // given MockMultipartFile file = new MockMultipartFile( "file", @@ -108,20 +108,31 @@ void uploadProfileImage_Success() { "image/png", "image-content".getBytes() ); - String uploadedUrl = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/profile.png"; + String storedUrl = "/uploads/profile-images/uuid-profile.png"; + String resolvedUrl = "http://localhost/uploads/profile-images/uuid-profile.png"; + + Worker worker = Worker.builder() + .workerCode("WRK001") + .bankName("카카오뱅크") + .accountNumber("123456789012") + .user(testUser) + .build(); when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); - when(profileImageStorageService.uploadProfileImage(1L, file)).thenReturn(uploadedUrl); + when(workerRepository.findByUserId(1L)).thenReturn(Optional.of(worker)); + when(profileImageStorageService.store(file)).thenReturn(storedUrl); + when(profileImageUrlResolver.resolve(storedUrl)).thenReturn(resolvedUrl); // when - UserDto.ProfileImageUploadResponse result = userService.uploadProfileImage(1L, file); + UserDto.Response result = userService.updateProfileImage(1L, file); // then assertThat(result).isNotNull(); - assertThat(result.getProfileImageUrl()).isEqualTo(uploadedUrl); - assertThat(testUser.getProfileImageUrl()).isEqualTo(uploadedUrl); - verify(userRepository).findById(1L); - verify(profileImageStorageService).uploadProfileImage(1L, file); + assertThat(result.getProfileImageUrl()).isEqualTo(resolvedUrl); + assertThat(testUser.getProfileImageUrl()).isEqualTo(storedUrl); + verify(userRepository, times(2)).findById(1L); + verify(profileImageStorageService).store(file); + verify(profileImageStorageService, never()).deleteIfStoredLocally(any()); } @Test