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 fbe046f7..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,9 +14,9 @@ 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.http.MediaType; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.bind.annotation.*; @@ -52,12 +52,13 @@ public ApiResponse updateMyInfo( return ApiResponse.success(userService.updateUser(user.getId(), request)); } - @Operation(summary = "내 프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 S3에 업로드하고 URL을 반환합니다.") + @Operation(summary = "내 프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드합니다.") @PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadMyProfileImage( + public ApiResponse uploadMyProfileImage( @AuthenticationPrincipal User user, - @Parameter(description = "프로필 이미지 파일", required = true) @RequestPart("file") MultipartFile file) { - return ApiResponse.success(userService.uploadProfileImage(user.getId(), file)); + @Parameter(description = "프로필 이미지 파일", required = true) + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(userService.updateProfileImage(user.getId(), file)); } @Operation(summary = "계좌 정보 수정 (근로자 전용)", description = "로그인한 근로자의 계좌 정보를 수정합니다.") 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 8d62636b..cb8adf65 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 index c69a97fe..3e7ec424 100644 --- a/src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java +++ b/src/main/java/com/example/paycheck/domain/user/service/ProfileImageStorageService.java @@ -2,20 +2,15 @@ import com.example.paycheck.common.exception.BadRequestException; import com.example.paycheck.common.exception.ErrorCode; -import com.example.paycheck.common.exception.FileUploadException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.exception.SdkException; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.model.GetUrlRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.S3Client; import java.io.IOException; -import java.io.InputStream; -import java.net.URL; +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; @@ -23,116 +18,95 @@ @Service public class ProfileImageStorageService { - private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - "image/heic", - "image/heif" - ); - private static final String DEFAULT_PROFILE_IMAGE_DIR = "profiles"; - - private final S3Client s3Client; - private final String bucket; - private final String profileImageDir; + private static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp"); + + private final Path profileImageDirectory; + private final String publicUriPrefix; public ProfileImageStorageService( - S3Client s3Client, - @Value("${aws.s3.bucket:}") String bucket, - @Value("${aws.s3.profile-image-dir:profiles}") String profileImageDir) { - this.s3Client = s3Client; - this.bucket = bucket; - this.profileImageDir = normalizeProfileImageDir(profileImageDir); + @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 uploadProfileImage(Long userId, MultipartFile file) { - validateFile(file); - validateBucketConfiguration(); + public String store(MultipartFile file) { + validate(file); - String contentType = file.getContentType(); - String objectKey = buildObjectKey(userId, file); - - try (InputStream inputStream = file.getInputStream()) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) - .key(objectKey) - .contentType(contentType) - .build(); - - s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); - - URL uploadedFileUrl = s3Client.utilities() - .getUrl(GetUrlRequest.builder().bucket(bucket).key(objectKey).build()); - - return uploadedFileUrl.toExternalForm(); - } catch (IOException | SdkException e) { - throw new FileUploadException( - ErrorCode.PROFILE_IMAGE_UPLOAD_FAILED, - "프로필 이미지 업로드에 실패했습니다.", - e - ); + 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); } } - private void validateFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new BadRequestException(ErrorCode.INVALID_PROFILE_IMAGE_FILE, "업로드할 이미지 파일이 비어 있습니다."); + public void deleteIfStoredLocally(String profileImageUrl) { + if (!StringUtils.hasText(profileImageUrl) || !profileImageUrl.startsWith(publicUriPrefix)) { + return; } - String contentType = file.getContentType(); - if (!StringUtils.hasText(contentType) || !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { - throw new BadRequestException( - ErrorCode.INVALID_PROFILE_IMAGE_FILE, - "프로필 이미지는 JPG, PNG, GIF, WEBP, HEIC 형식만 업로드할 수 있습니다." - ); + String fileName = profileImageUrl.substring(publicUriPrefix.length()); + if (!StringUtils.hasText(fileName)) { + return; } - } - private void validateBucketConfiguration() { - if (!StringUtils.hasText(bucket)) { - throw new FileUploadException( - ErrorCode.PROFILE_IMAGE_UPLOAD_NOT_CONFIGURED, - "프로필 이미지 업로드를 위한 S3 버킷 설정이 필요합니다." - ); + try { + Files.deleteIfExists(profileImageDirectory.resolve(fileName).normalize()); + } catch (IOException ignored) { + // 이전 프로필 이미지 정리에 실패해도 업로드 성공 흐름은 유지한다. } } - private String buildObjectKey(Long userId, MultipartFile file) { - String extension = resolveExtension(file); - String fileName = UUID.randomUUID() + (StringUtils.hasText(extension) ? "." + extension : ""); - return profileImageDir + "/" + userId + "/" + fileName; + public Path getProfileImageDirectory() { + return profileImageDirectory; } - private String resolveExtension(MultipartFile file) { - String originalExtension = StringUtils.getFilenameExtension(file.getOriginalFilename()); - if (StringUtils.hasText(originalExtension)) { - return sanitizeExtension(originalExtension); + public String getPublicUriPrefix() { + return publicUriPrefix; + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "업로드할 프로필 이미지 파일이 필요합니다."); } - return switch (file.getContentType().toLowerCase(Locale.ROOT)) { - case "image/jpeg", "image/jpg" -> "jpg"; - case "image/png" -> "png"; - case "image/gif" -> "gif"; - case "image/webp" -> "webp"; - case "image/heic" -> "heic"; - case "image/heif" -> "heif"; - default -> ""; - }; + String contentType = file.getContentType(); + if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "이미지 파일만 업로드할 수 있습니다."); + } + + extractExtension(file.getOriginalFilename()); } - private String sanitizeExtension(String extension) { + 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); - return normalizedExtension.matches("[a-z0-9]+") ? normalizedExtension : ""; + if (!ALLOWED_EXTENSIONS.contains(normalizedExtension)) { + throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "지원하지 않는 이미지 형식입니다."); + } + + return normalizedExtension; } - private String normalizeProfileImageDir(String profileImageDir) { - if (!StringUtils.hasText(profileImageDir)) { - return DEFAULT_PROFILE_IMAGE_DIR; + private String normalizePublicUriPrefix(String publicUriPrefix) { + String normalized = StringUtils.hasText(publicUriPrefix) ? publicUriPrefix.trim() : "/uploads/profile-images/"; + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; } - - String normalizedProfileImageDir = profileImageDir.replaceAll("^/+", "").replaceAll("/+$", ""); - return StringUtils.hasText(normalizedProfileImageDir) ? normalizedProfileImageDir : DEFAULT_PROFILE_IMAGE_DIR; + 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 d7ff5a77..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 @@ -27,6 +27,7 @@ public class UserService { 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) @@ -34,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 @@ -47,17 +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.ProfileImageUploadResponse uploadProfileImage(Long userId, MultipartFile file) { + public UserDto.Response updateProfileImage(Long userId, MultipartFile profileImage) { 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); + 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 @@ -88,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 00d5c080..3470673d 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/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/ProfileImageStorageServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java index 7560d191..3e15f09a 100644 --- a/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java +++ b/src/test/java/com/example/paycheck/domain/user/service/ProfileImageStorageServiceTest.java @@ -1,90 +1,62 @@ package com.example.paycheck.domain.user.service; import com.example.paycheck.common.exception.BadRequestException; -import com.example.paycheck.common.exception.FileUploadException; 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 software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Utilities; -import software.amazon.awssdk.services.s3.model.GetUrlRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectResponse; -import java.net.URL; +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; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; @DisplayName("ProfileImageStorageService 테스트") class ProfileImageStorageServiceTest { + @TempDir + Path tempDir; + @Test - @DisplayName("프로필 이미지를 S3에 업로드하고 URL을 반환한다") - void uploadProfileImage_Success() throws Exception { - S3Client s3Client = mock(S3Client.class); - S3Utilities s3Utilities = mock(S3Utilities.class); - ProfileImageStorageService service = new ProfileImageStorageService(s3Client, "test-bucket", "profiles"); + @DisplayName("프로필 이미지를 저장하고 공개 경로를 반환한다") + void store_Success() throws Exception { + ProfileImageStorageService storageService = new ProfileImageStorageService( + tempDir.resolve("profile-images").toString(), + "/uploads/profile-images/" + ); MockMultipartFile file = new MockMultipartFile( "file", - "profile.png", + "avatar.png", "image/png", - "image-content".getBytes() + "image-bytes".getBytes() ); - when(s3Client.putObject(any(PutObjectRequest.class), any(software.amazon.awssdk.core.sync.RequestBody.class))) - .thenReturn(PutObjectResponse.builder().eTag("etag").build()); - when(s3Client.utilities()).thenReturn(s3Utilities); - when(s3Utilities.getUrl(any(GetUrlRequest.class))) - .thenReturn(new URL("https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/profile.png")); + String storedPath = storageService.store(file); - String result = service.uploadProfileImage(1L, file); - - assertThat(result).isEqualTo("https://test-bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/profile.png"); - verify(s3Client).putObject(any(PutObjectRequest.class), any(software.amazon.awssdk.core.sync.RequestBody.class)); + assertThat(storedPath).startsWith("/uploads/profile-images/"); + try (var storedFiles = Files.list(storageService.getProfileImageDirectory())) { + assertThat(storedFiles.count()).isEqualTo(1); + } } @Test @DisplayName("이미지가 아닌 파일은 업로드할 수 없다") - void uploadProfileImage_Fail_InvalidContentType() { - S3Client s3Client = mock(S3Client.class); - ProfileImageStorageService service = new ProfileImageStorageService(s3Client, "test-bucket", "profiles"); + void store_Fail_InvalidContentType() { + ProfileImageStorageService storageService = new ProfileImageStorageService( + tempDir.resolve("profile-images").toString(), + "/uploads/profile-images/" + ); MockMultipartFile file = new MockMultipartFile( "file", - "profile.txt", + "avatar.txt", "text/plain", "not-image".getBytes() ); - assertThatThrownBy(() -> service.uploadProfileImage(1L, file)) - .isInstanceOf(BadRequestException.class) - .hasMessageContaining("프로필 이미지는"); - verifyNoInteractions(s3Client); - } - - @Test - @DisplayName("S3 버킷이 설정되지 않으면 업로드할 수 없다") - void uploadProfileImage_Fail_MissingBucket() { - S3Client s3Client = mock(S3Client.class); - ProfileImageStorageService service = new ProfileImageStorageService(s3Client, "", "profiles"); - - MockMultipartFile file = new MockMultipartFile( - "file", - "profile.png", - "image/png", - "image-content".getBytes() - ); - - assertThatThrownBy(() -> service.uploadProfileImage(1L, file)) - .isInstanceOf(FileUploadException.class) - .hasMessageContaining("S3 버킷 설정"); - verifyNoInteractions(s3Client); + 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 92804a6a..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 @@ -48,6 +48,9 @@ class UserServiceTest { @Mock private ProfileImageStorageService profileImageStorageService; + @Mock + private ProfileImageUrlResolver profileImageUrlResolver; + @InjectMocks private UserService userService; @@ -97,7 +100,7 @@ void updateUser_Fail_NotFound() { @Test @DisplayName("프로필 이미지 업로드 성공") - void uploadProfileImage_Success() { + void updateProfileImage_Success() { // given MockMultipartFile file = new MockMultipartFile( "file", @@ -105,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