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..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 @@ -10,19 +10,20 @@ import org.springframework.stereotype.Service @Service class ImageCommandService( private val imageManagementPort: ImageManagementPort, - private val userManagementPort: UserManagementPort + private val userManagementPort: UserManagementPort, ) : UploadImageUsecase { override fun upload(command: UploadImageUsecase.Command): UploadImageUsecase.Response { - val user = userManagementPort.getUserNotNull(DomainId(command.userId)) + val user = command.userId?.let { userManagementPort.getUserNotNull(DomainId(it)) } - val uploadedImage = imageManagementPort.save( - ImageMetadata( - owner = user.id.value, - fileMetaData = command.image + val uploadedImage = + imageManagementPort.save( + ImageMetadata( + owner = user?.id?.value, + fileMetaData = command.image, + ), ) - ) return UploadImageUsecase.Response( - imageUrl = uploadedImage.imageUrl + imageUrl = uploadedImage.imageUrl, ) } -} \ No newline at end of file +} 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 c170394a..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 @@ -6,14 +6,17 @@ 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.mockk.every import io.mockk.mockk +import io.mockk.verify import java.io.InputStream class ImageCommandServiceTest : BehaviorSpec({ + isolationMode = IsolationMode.InstancePerLeaf val mockImageManagementPort = mockk(relaxed = true) val mockUserManagementPort = mockk(relaxed = true) @@ -56,4 +59,36 @@ class ImageCommandServiceTest : } } } + + given("userId가 null인 이미지 업로드 요청이 들어올 때") { + val command = + UploadImageUsecase.Command( + userId = null, + image = + FileMetaData( + name = "name", + contentType = "contentType", + size = 1L, + inputStream = InputStream.nullInputStream(), + ), + ) + every { + mockImageManagementPort.save(any()) + } returns + UploadedImage( + imageUrl = "imageUrl", + ) + `when`("이미지 업로드 요청을 처리하면") { + val response = imageCommandService.upload(command) + then("getUserNotNull 메서드가 호출되지 않아야 한다") { + verify(exactly = 0) { mockUserManagementPort.getUserNotNull(any()) } + } + then("이미지가 저장되어야 한다") { + response.imageUrl shouldNotBeNull { + this.isNotBlank() + this.isNotEmpty() + } + } + } + } }) 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 +} 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" } }