From c4449da9e9dd9eeabcdfebcb14df20d3d607fab3 Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sun, 18 May 2025 17:00:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ASAP-456=20=EB=B9=84=ED=9A=8C=EC=9B=90=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=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `userId`를 nullable로 변경하여 비회원 이미지 업로드 지원. - 비회원 업로드 시 기본 ID로 "anonymous" 설정. - `ImageCommandServiceTest` 및 Controller 테스트에 관련 시나리오 추가. - API 문서 변경: 액세스 토큰 필드를 선택사항으로 수정하고 설명 추가. --- .../image/port/in/UploadImageUsecase.kt | 4 +-- .../image/service/ImageCommandService.kt | 31 +++++++++++----- .../image/service/ImageCommandServiceTest.kt | 36 +++++++++++++++++++ .../asap/bootstrap/common/config/WebConfig.kt | 8 ++--- .../annotation/AccessUserArgumentResolver.kt | 17 ++++----- .../asap/bootstrap/web/image/api/ImageApi.kt | 8 ++--- .../web/image/controller/ImageController.kt | 2 +- .../image/controller/ImageControllerTest.kt | 35 ++++++++++++++++++ .../common/security/SecurityContextHolder.kt | 6 ++-- 9 files changed, 113 insertions(+), 34 deletions(-) diff --git a/Application-Module/src/main/kotlin/com/asap/application/image/port/in/UploadImageUsecase.kt b/Application-Module/src/main/kotlin/com/asap/application/image/port/in/UploadImageUsecase.kt index 5f4f25b3..daf07edf 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/image/port/in/UploadImageUsecase.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/image/port/in/UploadImageUsecase.kt @@ -9,10 +9,10 @@ interface UploadImageUsecase { data class Command( val image: FileMetaData, - val userId: String + val userId: String? = null ) data class Response( val imageUrl: String ) -} \ No newline at end of file +} diff --git a/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt b/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt index c4f809d7..c304503e 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt @@ -10,19 +10,32 @@ import org.springframework.stereotype.Service @Service class ImageCommandService( private val imageManagementPort: ImageManagementPort, - private val userManagementPort: UserManagementPort + private val userManagementPort: UserManagementPort, ) : UploadImageUsecase { + companion object { + private const val ANONYMOUS_OWNER_ID = "anonymous" + } + override fun upload(command: UploadImageUsecase.Command): UploadImageUsecase.Response { - val user = userManagementPort.getUserNotNull(DomainId(command.userId)) + val owner = + when { + command.userId != null -> { + val user = userManagementPort.getUserNotNull(DomainId(command.userId)) + user.id.value + } - val uploadedImage = imageManagementPort.save( - ImageMetadata( - owner = user.id.value, - fileMetaData = command.image + else -> ANONYMOUS_OWNER_ID + } + + val uploadedImage = + imageManagementPort.save( + ImageMetadata( + owner = owner, + fileMetaData = command.image, + ), ) - ) return UploadImageUsecase.Response( - imageUrl = uploadedImage.imageUrl + imageUrl = uploadedImage.imageUrl, ) } -} \ No newline at end of file +} diff --git a/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt index c170394a..9d61b305 100644 --- a/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt +++ b/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt @@ -2,14 +2,18 @@ package com.asap.application.image.service import com.asap.application.image.port.`in`.UploadImageUsecase import com.asap.application.image.port.out.ImageManagementPort +import com.asap.application.image.vo.ImageMetadata import com.asap.application.image.vo.UploadedImage import com.asap.application.user.port.out.UserManagementPort import com.asap.common.file.FileMetaData import com.asap.domain.UserFixture import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import java.io.InputStream class ImageCommandServiceTest : @@ -24,6 +28,38 @@ class ImageCommandServiceTest : mockUserManagementPort, ) + given("익명 사용자의 이미지 업로드 요청이 들어올 때") { + val command = + UploadImageUsecase.Command( + userId = null, + image = + FileMetaData( + name = "name", + contentType = "contentType", + size = 1L, + inputStream = InputStream.nullInputStream(), + ), + ) + val imageMetadataSlot = slot() + every { + mockImageManagementPort.save(capture(imageMetadataSlot)) + } returns + UploadedImage( + imageUrl = "anonymousImageUrl", + ) + `when`("이미지 업로드 요청을 처리하면") { + val response = imageCommandService.upload(command) + then("이미지가 저장되어야 한다") { + response.imageUrl shouldNotBeNull { + this.isNotBlank() + this.isNotEmpty() + } + verify { mockImageManagementPort.save(any()) } + imageMetadataSlot.captured.owner shouldBe "anonymous" + } + } + } + given("이미지 업로드 요청이 들어올 때") { val mockUser = UserFixture.createUser() val command = diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/config/WebConfig.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/config/WebConfig.kt index 429219b7..f5278cd4 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/config/WebConfig.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/config/WebConfig.kt @@ -8,11 +8,11 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration class WebConfig( - private val accessUserArgumentResolver: AccessUserArgumentResolver + private val accessUserArgumentResolver: AccessUserArgumentResolver, ) : WebMvcConfigurer { - override fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("/**") + registry + .addMapping("/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") @@ -21,4 +21,4 @@ class WebConfig( override fun addArgumentResolvers(resolvers: MutableList) { resolvers.add(accessUserArgumentResolver) } -} \ No newline at end of file +} diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/security/annotation/AccessUserArgumentResolver.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/security/annotation/AccessUserArgumentResolver.kt index ba24e833..50a4b8a3 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/security/annotation/AccessUserArgumentResolver.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/common/security/annotation/AccessUserArgumentResolver.kt @@ -10,20 +10,17 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer @Component -class AccessUserArgumentResolver: HandlerMethodArgumentResolver { - - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(AccessUser::class.java) - } +class AccessUserArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.hasParameterAnnotation(AccessUser::class.java) override fun resolveArgument( parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? + binderFactory: WebDataBinderFactory?, ): Any? { - val userAuthentication = SecurityContextHolder.getContext().getAuthentication() as UserAuthentication - val userId = userAuthentication.getDetails() - return userId + val authentication = SecurityContextHolder.getContext()?.getAuthentication() + val userAuthentication = authentication as? UserAuthentication + return userAuthentication?.getDetails() } -} \ No newline at end of file +} diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/api/ImageApi.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/api/ImageApi.kt index e817c6d8..39f18827 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/api/ImageApi.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/api/ImageApi.kt @@ -17,7 +17,7 @@ import org.springframework.web.multipart.MultipartFile interface ImageApi { @Operation( summary = "이미지 업로드", - description = "이미지를 업로드합니다.", + description = "이미지를 업로드합니다. 회원과 비회원 모두 이용 가능합니다.", ) @PostMapping(consumes = ["multipart/form-data"]) @ApiResponses( @@ -28,8 +28,8 @@ interface ImageApi { headers = [ Header( name = "Authorization", - description = "액세스 토큰", - required = true, + description = "액세스 토큰 (선택사항)", + required = false, ), ], ), @@ -41,6 +41,6 @@ interface ImageApi { ) fun uploadImage( @RequestPart image: MultipartFile, - @AccessUser userId: String, + @AccessUser userId: String?, ): UploadImageResponse } diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/controller/ImageController.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/controller/ImageController.kt index a1513d9b..43f23f92 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/controller/ImageController.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/web/image/controller/ImageController.kt @@ -14,7 +14,7 @@ class ImageController( ) : ImageApi { override fun uploadImage( image: MultipartFile, - userId: String, + userId: String?, ): UploadImageResponse { val response = uploadImageUsecase.upload( diff --git a/Bootstrap-Module/src/test/kotlin/com/asap/bootstrap/acceptance/image/controller/ImageControllerTest.kt b/Bootstrap-Module/src/test/kotlin/com/asap/bootstrap/acceptance/image/controller/ImageControllerTest.kt index 872fa23b..84134057 100644 --- a/Bootstrap-Module/src/test/kotlin/com/asap/bootstrap/acceptance/image/controller/ImageControllerTest.kt +++ b/Bootstrap-Module/src/test/kotlin/com/asap/bootstrap/acceptance/image/controller/ImageControllerTest.kt @@ -54,4 +54,39 @@ class ImageControllerTest : AcceptanceSupporter() { } } } + + @Test + fun uploadImageWithoutAuthentication() { + // given + val mockFile = MockMultipartFile("image", "test.jpg", "image/jpeg", "test".toByteArray()) + val mockFileMetaData = FileMetaData("test.jpg", 4, "image/jpeg", mockFile.inputStream) + BDDMockito + .given(fileConverter.convert(mockFile)) + .willReturn(mockFileMetaData) + BDDMockito + .given( + uploadImageUsecase.upload( + UploadImageUsecase.Command( + image = mockFileMetaData, + userId = null, + ), + ), + ).willReturn(UploadImageUsecase.Response("imageUrl")) + // when + val response = + mockMvc.multipart("/api/v1/images") { + file(mockFile) + contentType = MediaType.MULTIPART_FORM_DATA + // No Authorization header + } + // then + response.andExpect { + status { isOk() } + jsonPath("$.imageUrl") { + exists() + isString() + isNotEmpty() + } + } + } } diff --git a/Common-Module/src/main/kotlin/com/asap/common/security/SecurityContextHolder.kt b/Common-Module/src/main/kotlin/com/asap/common/security/SecurityContextHolder.kt index 633599a0..e07f057d 100644 --- a/Common-Module/src/main/kotlin/com/asap/common/security/SecurityContextHolder.kt +++ b/Common-Module/src/main/kotlin/com/asap/common/security/SecurityContextHolder.kt @@ -4,9 +4,7 @@ class SecurityContextHolder { companion object { private val contextHolder = ThreadLocal>() - fun getContext(): SecurityContext<*, *> { - return contextHolder.get() - } + fun getContext(): SecurityContext<*, *>? = contextHolder.get() fun setContext(context: SecurityContext<*, *>) { contextHolder.set(context) @@ -16,4 +14,4 @@ class SecurityContextHolder { contextHolder.remove() } } -} \ No newline at end of file +} From a9530359396640da3651d55154c7e146f4d22f55 Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sun, 18 May 2025 17:10:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ASAP-456=20=EB=B9=84=ED=9A=8C=EC=9B=90=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=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ImageMetadata.owner`를 nullable로 변경하여 비회원 지원. - 비회원 이미지 업로드 시 기본 ID("anonymous") 설정 로직을 `S3ImageManagementAdapter`로 이동. - `upload` 메서드의 비회원 처리 로직 단순화. - `ImageCommandServiceTest` 테스트 구조 개선: 시나리오 분리 및 검증 추가. - `ImageMetadata` 데이터 클래스 구조 수정. --- .../image/service/ImageCommandService.kt | 16 ++-------- .../application/image/vo/ImageMetadata.kt | 7 ++--- .../image/service/ImageCommandServiceTest.kt | 31 +++++++++---------- .../asap/aws/s3/S3ImageManagementAdapter.kt | 5 ++- 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt b/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt index c304503e..1f93bb11 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/image/service/ImageCommandService.kt @@ -12,25 +12,13 @@ class ImageCommandService( private val imageManagementPort: ImageManagementPort, private val userManagementPort: UserManagementPort, ) : UploadImageUsecase { - companion object { - private const val ANONYMOUS_OWNER_ID = "anonymous" - } - override fun upload(command: UploadImageUsecase.Command): UploadImageUsecase.Response { - val owner = - when { - command.userId != null -> { - val user = userManagementPort.getUserNotNull(DomainId(command.userId)) - user.id.value - } - - else -> ANONYMOUS_OWNER_ID - } + val user = command.userId?.let { userManagementPort.getUserNotNull(DomainId(it)) } val uploadedImage = imageManagementPort.save( ImageMetadata( - owner = owner, + owner = user?.id?.value, fileMetaData = command.image, ), ) diff --git a/Application-Module/src/main/kotlin/com/asap/application/image/vo/ImageMetadata.kt b/Application-Module/src/main/kotlin/com/asap/application/image/vo/ImageMetadata.kt index 61c3ab46..b091fdc4 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/image/vo/ImageMetadata.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/image/vo/ImageMetadata.kt @@ -3,7 +3,6 @@ package com.asap.application.image.vo import com.asap.common.file.FileMetaData data class ImageMetadata( - val owner: String, - val fileMetaData: FileMetaData -) { -} \ No newline at end of file + val owner: String?, + val fileMetaData: FileMetaData, +) diff --git a/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt index 9d61b305..d417ae20 100644 --- a/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt +++ b/Application-Module/src/test/kotlin/com/asap/application/image/service/ImageCommandServiceTest.kt @@ -2,22 +2,21 @@ package com.asap.application.image.service import com.asap.application.image.port.`in`.UploadImageUsecase import com.asap.application.image.port.out.ImageManagementPort -import com.asap.application.image.vo.ImageMetadata import com.asap.application.image.vo.UploadedImage import com.asap.application.user.port.out.UserManagementPort import com.asap.common.file.FileMetaData import com.asap.domain.UserFixture +import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import java.io.InputStream class ImageCommandServiceTest : BehaviorSpec({ + isolationMode = IsolationMode.InstancePerLeaf val mockImageManagementPort = mockk(relaxed = true) val mockUserManagementPort = mockk(relaxed = true) @@ -28,10 +27,11 @@ class ImageCommandServiceTest : mockUserManagementPort, ) - given("익명 사용자의 이미지 업로드 요청이 들어올 때") { + given("이미지 업로드 요청이 들어올 때") { + val mockUser = UserFixture.createUser() val command = UploadImageUsecase.Command( - userId = null, + userId = mockUser.id.value, image = FileMetaData( name = "name", @@ -40,12 +40,14 @@ class ImageCommandServiceTest : inputStream = InputStream.nullInputStream(), ), ) - val imageMetadataSlot = slot() every { - mockImageManagementPort.save(capture(imageMetadataSlot)) + mockUserManagementPort.getUserNotNull(any()) + } returns mockUser + every { + mockImageManagementPort.save(any()) } returns UploadedImage( - imageUrl = "anonymousImageUrl", + imageUrl = "imageUrl", ) `when`("이미지 업로드 요청을 처리하면") { val response = imageCommandService.upload(command) @@ -54,17 +56,14 @@ class ImageCommandServiceTest : this.isNotBlank() this.isNotEmpty() } - verify { mockImageManagementPort.save(any()) } - imageMetadataSlot.captured.owner shouldBe "anonymous" } } } - given("이미지 업로드 요청이 들어올 때") { - val mockUser = UserFixture.createUser() + given("userId가 null인 이미지 업로드 요청이 들어올 때") { val command = UploadImageUsecase.Command( - userId = mockUser.id.value, + userId = null, image = FileMetaData( name = "name", @@ -73,9 +72,6 @@ class ImageCommandServiceTest : inputStream = InputStream.nullInputStream(), ), ) - every { - mockUserManagementPort.getUserNotNull(any()) - } returns mockUser every { mockImageManagementPort.save(any()) } returns @@ -84,6 +80,9 @@ class ImageCommandServiceTest : ) `when`("이미지 업로드 요청을 처리하면") { val response = imageCommandService.upload(command) + then("getUserNotNull 메서드가 호출되지 않아야 한다") { + verify(exactly = 0) { mockUserManagementPort.getUserNotNull(any()) } + } then("이미지가 저장되어야 한다") { response.imageUrl shouldNotBeNull { this.isNotBlank() diff --git a/Infrastructure-Module/AWS/src/main/kotlin/com/asap/aws/s3/S3ImageManagementAdapter.kt b/Infrastructure-Module/AWS/src/main/kotlin/com/asap/aws/s3/S3ImageManagementAdapter.kt index fb46e992..fd1135de 100644 --- a/Infrastructure-Module/AWS/src/main/kotlin/com/asap/aws/s3/S3ImageManagementAdapter.kt +++ b/Infrastructure-Module/AWS/src/main/kotlin/com/asap/aws/s3/S3ImageManagementAdapter.kt @@ -13,7 +13,9 @@ class S3ImageManagementAdapter( private val s3Template: S3Template, ) : ImageManagementPort { override fun save(image: ImageMetadata): UploadedImage { - val key = "${image.owner}/${UUID.randomUUID()}" + val owner = image.owner ?: ANONYMOUS_OWNER_ID + + val key = "$owner/${UUID.randomUUID()}" val resource = s3Template.upload( @@ -33,5 +35,6 @@ class S3ImageManagementAdapter( companion object { private const val BUCKET_NAME = "lettering-images" + private const val ANONYMOUS_OWNER_ID = "anonymous" } }