From 6c61ddfa94d613618fbb4e623ab9c073f1bb1779 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:05:56 +0900 Subject: [PATCH 01/21] =?UTF-8?q?chore:=20`.pem`=20ignore=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7b8725e..34f2266 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ src/test/generated/ ### env ### .env + +### key ### +*.pem From ae47688c6b8d91ad963479b16ae98586a10e948a Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:06:21 +0900 Subject: [PATCH 02/21] =?UTF-8?q?chore:=20oci=20object=20storage=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index adf18a2..2865e3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,9 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") + implementation("com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.77.2") + implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.77.2") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") testImplementation("com.h2database:h2") From 33dcfda04ab09d764174b9932aab55dea0cd9630 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:06:35 +0900 Subject: [PATCH 03/21] =?UTF-8?q?chore:=20oci=20object=20storage=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20profile=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f0b4b94..fc60f72 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,3 +61,13 @@ target: image: path: images/ +oci: + tenant-id: ${OCI_TENANT_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key-path: ${OCI_PRIVATE_KEY_PATH} + region: ${OCI_REGION} + bucket: + name: ${OCI_BUCKET_NAME} + namespace: ${OCI_BUCKET_NAMESPACE} + From 7906330ffc23bbba458ccf967131a6e38ef58c5d Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:11:36 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20oci=20object=20storage=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../land/leets/global/config/OciConfig.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/kotlin/land/leets/global/config/OciConfig.kt diff --git a/src/main/kotlin/land/leets/global/config/OciConfig.kt b/src/main/kotlin/land/leets/global/config/OciConfig.kt new file mode 100644 index 0000000..a749257 --- /dev/null +++ b/src/main/kotlin/land/leets/global/config/OciConfig.kt @@ -0,0 +1,55 @@ +package land.leets.global.config + +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.ObjectStorageClient +import io.github.oshai.kotlinlogging.KotlinLogging +import land.leets.global.error.ErrorCode +import land.leets.global.error.exception.OciConfigFailException +import land.leets.global.error.exception.ServiceException +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.io.FileInputStream +import java.io.InputStream +import java.util.function.Supplier + +private val log = KotlinLogging.logger {} + +@Configuration +class OciConfig( + @Value("\${oci.tenant-id}") + private val tenantId: String, + @Value("\${oci.user-id}") + private val userId: String, + @Value("\${oci.fingerprint}") + private val fingerprint: String, + @Value("\${oci.private-key-path}") + private val privateKeyPath: String, + @Value("\${oci.region}") + private val region: String, + @Value("\${oci.bucket.namespace}") + private val bucketNamespace: String +) { + + @Bean + fun objectStorageClient(): ObjectStorage = + createStorageClient(createAuthenticationProvider()) + .also { client -> + client.endpoint = "https://$bucketNamespace.objectstorage.$region.oci.customer-oci.com" + } + + private fun createAuthenticationProvider(): SimpleAuthenticationDetailsProvider = + SimpleAuthenticationDetailsProvider.builder().apply { + tenantId(tenantId) + userId(userId) + fingerprint(fingerprint) + privateKeySupplier(createPrivateKeySupplier()) + }.build() + + private fun createPrivateKeySupplier(): Supplier = + Supplier { FileInputStream(privateKeyPath) } + + private fun createStorageClient(provider: SimpleAuthenticationDetailsProvider): ObjectStorage = + ObjectStorageClient.builder().build(provider) +} From 6a950693266d289ed028125365572bf08298129c Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:15:05 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20Pre-Authenticated=20Url=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/presentation/dto/PreAuthenticatedUrlResponse.kt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt b/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt new file mode 100644 index 0000000..84f70c2 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/presentation/dto/PreAuthenticatedUrlResponse.kt @@ -0,0 +1,5 @@ +package land.leets.domain.storage.presentation.dto + +data class PreAuthenticatedUrlResponse( + val url: String, +) From 6b6e5d53a670ed62a7bad5cd68146f28d95fb353 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:15:14 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20Pre-Authenticated=20Url=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/usecase/GetPreAuthenticatedUrl.kt | 7 ++ .../usecase/GetPreAuthenticatedUrlImpl.kt | 73 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt create mode 100644 src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt new file mode 100644 index 0000000..0e10371 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrl.kt @@ -0,0 +1,7 @@ +package land.leets.domain.storage.usecase + +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse + +interface GetPreAuthenticatedUrl { + fun execute(fileName: String): PreAuthenticatedUrlResponse +} diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt new file mode 100644 index 0000000..056dc2d --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt @@ -0,0 +1,73 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails +import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +@Service +class GetPreAuthenticatedUrlImpl( + private val objectStorage: ObjectStorage, + @Value("\${oci.bucket.name}") private val bucketName: String, + @Value("\${oci.bucket.namespace}") private val bucketNamespace: String, +) : GetPreAuthenticatedUrl { + + companion object { + private const val PRESIGNED_URL_EXPIRATION_MINUTES = 15L + private const val PRESIGNED_REQUEST_NAME_PREFIX = "PAR_Request_" + } + + override fun execute(fileName: String): PreAuthenticatedUrlResponse { + val uniqueFileName = generateUniqueFileName(fileName) + val expirationTime = calculateExpirationTime() + + val details = buildPreAuthenticatedRequestDetails(uniqueFileName, expirationTime) + val request = buildPreAuthenticatedRequest(details) + + val response = objectStorage.createPreauthenticatedRequest(request) + val fullUrl = buildFullUrl(response.preauthenticatedRequest.accessUri) + + return PreAuthenticatedUrlResponse(fullUrl) + } + + private fun generateUniqueFileName(fileName: String): String { + val uuid = UUID.randomUUID() + + return if ('/' in fileName) { + "${fileName.substringBeforeLast('/')}/$uuid${"_"}${fileName.substringAfterLast('/')}" + } else { + "${uuid}_$fileName" + } + } + + private fun calculateExpirationTime(): Date = + Date.from(Instant.now().plus(PRESIGNED_URL_EXPIRATION_MINUTES, ChronoUnit.MINUTES)) + + private fun buildPreAuthenticatedRequestDetails( + fileName: String, + expirationTime: Date + ): CreatePreauthenticatedRequestDetails = + CreatePreauthenticatedRequestDetails.builder().apply { + name("$PRESIGNED_REQUEST_NAME_PREFIX${UUID.randomUUID()}") + objectName(fileName) + accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + timeExpires(expirationTime) + }.build() + + private fun buildPreAuthenticatedRequest( + details: CreatePreauthenticatedRequestDetails + ): CreatePreauthenticatedRequestRequest = + CreatePreauthenticatedRequestRequest.builder().apply { + namespaceName(bucketNamespace) + bucketName(bucketName) + createPreauthenticatedRequestDetails(details) + }.build() + + private fun buildFullUrl(accessUri: String): String = + "${objectStorage.endpoint}$accessUri" +} From cee2430f8153a5d26b50f049f5138ae21e8f0a98 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:16:44 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20oci=20object=20storage=20?= =?UTF-8?q?=EC=9D=98=20object=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재는 사용하지 않는 기능이므로, `@Service` 미사용 --- .../leets/domain/storage/usecase/GetObjectUrl.kt | 5 +++++ .../domain/storage/usecase/GetObjectUrlImpl.kt | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt create mode 100644 src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt new file mode 100644 index 0000000..6a4baf1 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrl.kt @@ -0,0 +1,5 @@ +package land.leets.domain.storage.usecase + +interface GetObjectUrl { + fun execute(fileName: String): String +} diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt new file mode 100644 index 0000000..0d85ff7 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetObjectUrlImpl.kt @@ -0,0 +1,15 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import org.springframework.beans.factory.annotation.Value + + +class GetObjectUrlImpl( + private val objectStorage: ObjectStorage, + @Value("\${oci.bucket.name}") private val bucketName: String, + @Value("\${oci.bucket.namespace}") private val bucketNamespace: String, +) : GetObjectUrl { + + override fun execute(fileName: String): String = + "${objectStorage.endpoint}/n/$bucketNamespace/b/$bucketName/o/$fileName" +} From b06b1db90819ae1d662934448792af30742aac2b Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:44:29 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20oci=20object=20storage=20Rest=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/presentation/StorageController.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt new file mode 100644 index 0000000..527f7ba --- /dev/null +++ b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt @@ -0,0 +1,39 @@ +package land.leets.domain.storage.presentation + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse +import land.leets.domain.storage.usecase.GetPreAuthenticatedUrl +import land.leets.global.error.ErrorResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/storages") +class StorageController( + private val getPreAuthenticatedUrl: GetPreAuthenticatedUrl +) { + + @Operation(summary = "Pre-Authenticated Url 발급", description = "OCI Object Storage에 파일 업로드를 위한 Pre-Authenticated Url을 발급합니다.") + @ApiResponses( + ApiResponse(responseCode = "200"), + ApiResponse(responseCode = "400", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "403", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "404", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))]) + ) + @PostMapping("/pre-authenticated-url") + fun getPresignedUrl( + @Parameter(description = "버킷에 업로드할 파일 이름 및 경로", example = "profile/{username}.jpg") + @RequestParam fileName: String + ): ResponseEntity { + return ResponseEntity.ok(getPreAuthenticatedUrl.execute(fileName)) + } +} From b3aad05ea372f4c620cee37ba8630b72c586b846 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:51:30 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20oci=20object=20storage=20API=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/land/leets/global/config/SecurityConfig.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt index 4e47c22..246b2af 100644 --- a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt +++ b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt @@ -98,6 +98,9 @@ class SecurityConfig( // images authorize(HttpMethod.GET, "/images/{imageName}", permitAll) + // storages + authorize(HttpMethod.POST, "/storages/presigned-url", hasAnyAuthority(AuthRole.ROLE_USER.role)) + // default authorize(anyRequest, denyAll) } From 2343a1688b1ca0d66f0a80cebe7763eb826a50b5 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:59:27 +0900 Subject: [PATCH 10/21] =?UTF-8?q?delete:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/land/leets/global/config/OciConfig.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/land/leets/global/config/OciConfig.kt b/src/main/kotlin/land/leets/global/config/OciConfig.kt index a749257..80d0f8e 100644 --- a/src/main/kotlin/land/leets/global/config/OciConfig.kt +++ b/src/main/kotlin/land/leets/global/config/OciConfig.kt @@ -4,9 +4,6 @@ import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider import com.oracle.bmc.objectstorage.ObjectStorage import com.oracle.bmc.objectstorage.ObjectStorageClient import io.github.oshai.kotlinlogging.KotlinLogging -import land.leets.global.error.ErrorCode -import land.leets.global.error.exception.OciConfigFailException -import land.leets.global.error.exception.ServiceException import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration From d20c1975d387c4efccf3b7e0ac09e7a9a9b91b35 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:00:08 +0900 Subject: [PATCH 11/21] =?UTF-8?q?test:=20Pre-Authenticated=20Url=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetPreAuthenticatedUrlImplTest.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt diff --git a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt new file mode 100644 index 0000000..7c5e440 --- /dev/null +++ b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt @@ -0,0 +1,43 @@ +package land.leets.domain.storage.usecase + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.model.PreauthenticatedRequest +import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetPreAuthenticatedUrlImplTest : DescribeSpec({ + + val objectStorage = mockk() + val bucketName = "test-bucket" + val namespaceName = "test-namespace" + val region = "ap-seoul-1" + val endpoint = "https://$namespaceName.objectstorage.$region.oci.customer-oci.com" + + val getPresignedUrl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName) + + describe("GetPresignedUrlImpl") { + context("Presigned URL 생성을 요청할 때") { + val fileName = "test.jpg" + val accessUri = "/p/some-random-string/b/bucket/o/test.jpg" + + val request = PreauthenticatedRequest.builder().accessUri(accessUri).build() + val response = CreatePreauthenticatedRequestResponse.builder() + .preauthenticatedRequest(request) + .build() + + every { objectStorage.createPreauthenticatedRequest(any()) } returns response + every { objectStorage.endpoint } returns endpoint + + it("객체 업로드 권한이 있는 전체 URL을 반환한다") { + val result = getPresignedUrl.execute(fileName) + + result.url shouldBe "$endpoint$accessUri" + verify { objectStorage.createPreauthenticatedRequest(any()) } + } + } + } +}) From 12b8a5e4e44ba2cb693974527942e6362a947c93 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:26:18 +0900 Subject: [PATCH 12/21] =?UTF-8?q?delete:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/land/leets/global/config/OciConfig.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/land/leets/global/config/OciConfig.kt b/src/main/kotlin/land/leets/global/config/OciConfig.kt index 80d0f8e..94d2446 100644 --- a/src/main/kotlin/land/leets/global/config/OciConfig.kt +++ b/src/main/kotlin/land/leets/global/config/OciConfig.kt @@ -3,7 +3,6 @@ package land.leets.global.config import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider import com.oracle.bmc.objectstorage.ObjectStorage import com.oracle.bmc.objectstorage.ObjectStorageClient -import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -11,8 +10,6 @@ import java.io.FileInputStream import java.io.InputStream import java.util.function.Supplier -private val log = KotlinLogging.logger {} - @Configuration class OciConfig( @Value("\${oci.tenant-id}") From 607cf8340976779fe87c89f283e01f999acb66eb Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:38:36 +0900 Subject: [PATCH 13/21] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../land/leets/domain/storage/presentation/StorageController.kt | 2 +- src/main/kotlin/land/leets/global/config/SecurityConfig.kt | 2 +- .../domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt index 527f7ba..07fcc5b 100644 --- a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt +++ b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt @@ -30,7 +30,7 @@ class StorageController( ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))]) ) @PostMapping("/pre-authenticated-url") - fun getPresignedUrl( + fun getPreAuthenticatedUrl( @Parameter(description = "버킷에 업로드할 파일 이름 및 경로", example = "profile/{username}.jpg") @RequestParam fileName: String ): ResponseEntity { diff --git a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt index 246b2af..cca7061 100644 --- a/src/main/kotlin/land/leets/global/config/SecurityConfig.kt +++ b/src/main/kotlin/land/leets/global/config/SecurityConfig.kt @@ -99,7 +99,7 @@ class SecurityConfig( authorize(HttpMethod.GET, "/images/{imageName}", permitAll) // storages - authorize(HttpMethod.POST, "/storages/presigned-url", hasAnyAuthority(AuthRole.ROLE_USER.role)) + authorize(HttpMethod.POST, "/storages/pre-authenticated-url", hasAnyAuthority(AuthRole.ROLE_USER.role)) // default authorize(anyRequest, denyAll) diff --git a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt index 7c5e440..1e47f40 100644 --- a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt +++ b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt @@ -19,7 +19,7 @@ class GetPreAuthenticatedUrlImplTest : DescribeSpec({ val getPresignedUrl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName) - describe("GetPresignedUrlImpl") { + describe("GetPreAuthenticatedUrl") { context("Presigned URL 생성을 요청할 때") { val fileName = "test.jpg" val accessUri = "/p/some-random-string/b/bucket/o/test.jpg" From dae92e80e6e0b6bceaba0a4392251311d2865add Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:41:04 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../land/leets/domain/storage/presentation/StorageController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt index 07fcc5b..dbd22d5 100644 --- a/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt +++ b/src/main/kotlin/land/leets/domain/storage/presentation/StorageController.kt @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.constraints.NotBlank import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse import land.leets.domain.storage.usecase.GetPreAuthenticatedUrl import land.leets.global.error.ErrorResponse @@ -31,6 +32,7 @@ class StorageController( ) @PostMapping("/pre-authenticated-url") fun getPreAuthenticatedUrl( + @NotBlank @Parameter(description = "버킷에 업로드할 파일 이름 및 경로", example = "profile/{username}.jpg") @RequestParam fileName: String ): ResponseEntity { From f7708909ebd5d081248a16f5a8744f08f5fefc09 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:41:25 +0900 Subject: [PATCH 15/21] =?UTF-8?q?refactor:=20=ED=91=9C=ED=98=84=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt index 056dc2d..e5faa95 100644 --- a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt @@ -39,7 +39,7 @@ class GetPreAuthenticatedUrlImpl( val uuid = UUID.randomUUID() return if ('/' in fileName) { - "${fileName.substringBeforeLast('/')}/$uuid${"_"}${fileName.substringAfterLast('/')}" + "${fileName.substringBeforeLast('/')}/${uuid}_${fileName.substringAfterLast('/')}" } else { "${uuid}_$fileName" } From 4bb85733d1b09bdc951c26715dd69c927c025261 Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:02:12 +0900 Subject: [PATCH 16/21] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt index 1e47f40..551bd8d 100644 --- a/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt +++ b/src/test/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImplTest.kt @@ -17,7 +17,7 @@ class GetPreAuthenticatedUrlImplTest : DescribeSpec({ val region = "ap-seoul-1" val endpoint = "https://$namespaceName.objectstorage.$region.oci.customer-oci.com" - val getPresignedUrl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName) + val getPreAuthenticatedUrlImpl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName) describe("GetPreAuthenticatedUrl") { context("Presigned URL 생성을 요청할 때") { @@ -33,7 +33,7 @@ class GetPreAuthenticatedUrlImplTest : DescribeSpec({ every { objectStorage.endpoint } returns endpoint it("객체 업로드 권한이 있는 전체 URL을 반환한다") { - val result = getPresignedUrl.execute(fileName) + val result = getPreAuthenticatedUrlImpl.execute(fileName) result.url shouldBe "$endpoint$accessUri" verify { objectStorage.createPreauthenticatedRequest(any()) } From 57fdf9c34724cdfcb5ea36d12cd2880819c8b45c Mon Sep 17 00:00:00 2001 From: yechan-kim <60172300+yechan-kim@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:07:52 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20=EC=83=81=EC=88=98=EB=A5=BC?= =?UTF-8?q?=20top-level=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt index e5faa95..d3595d3 100644 --- a/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt +++ b/src/main/kotlin/land/leets/domain/storage/usecase/GetPreAuthenticatedUrlImpl.kt @@ -10,6 +10,9 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.* +private const val PRESIGNED_URL_EXPIRATION_MINUTES = 15L +private const val PRESIGNED_REQUEST_NAME_PREFIX = "PAR_Request_" + @Service class GetPreAuthenticatedUrlImpl( private val objectStorage: ObjectStorage, @@ -17,11 +20,6 @@ class GetPreAuthenticatedUrlImpl( @Value("\${oci.bucket.namespace}") private val bucketNamespace: String, ) : GetPreAuthenticatedUrl { - companion object { - private const val PRESIGNED_URL_EXPIRATION_MINUTES = 15L - private const val PRESIGNED_REQUEST_NAME_PREFIX = "PAR_Request_" - } - override fun execute(fileName: String): PreAuthenticatedUrlResponse { val uniqueFileName = generateUniqueFileName(fileName) val expirationTime = calculateExpirationTime() From 777feb75fd0ebed12e1101a91224c804908d5eee Mon Sep 17 00:00:00 2001 From: Jeongwan Noh Date: Tue, 30 Dec 2025 12:51:17 +0900 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20DTO?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/ApplicationController.kt | 18 ++++++++++- .../dto/ApplicationStatusResponse.kt | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt diff --git a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt index 24ecde5..cc3b4cb 100644 --- a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt +++ b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt @@ -10,6 +10,7 @@ import land.leets.domain.application.domain.Application import land.leets.domain.application.presentation.dto.ApplicationDetailsResponse import land.leets.domain.application.presentation.dto.ApplicationRequest import land.leets.domain.application.presentation.dto.ApplicationResponse +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse import land.leets.domain.application.presentation.dto.StatusRequest import land.leets.domain.application.usecase.* import land.leets.domain.auth.AuthDetails @@ -25,7 +26,8 @@ class ApplicationController( private val updateApplication: UpdateApplication, private val getApplication: GetAllApplication, private val getApplicationDetails: GetApplicationDetails, - private val updateResult: UpdateResult + private val updateResult: UpdateResult, + private val getApplicationStatus: GetApplicationStatus, ) { @Operation(summary = "(유저) 지원서 작성", description = "지원서를 작성합니다.") @@ -116,4 +118,18 @@ class ApplicationController( val uid = authDetails.uid return getApplicationDetails.execute(uid) } + + @Operation(summary = "(유저) 지원서 상태 불러오기", description = "작성한 지원서 상태를 불러옵니다.") + @ApiResponses( + ApiResponse(responseCode = "200"), + ApiResponse(responseCode = "400", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "403", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "404", content = [Content(schema = Schema(implementation = ErrorResponse::class))]), + ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))]) + ) + @GetMapping("/status") + fun getStatus(@AuthenticationPrincipal authDetails: AuthDetails): ApplicationStatusResponse { + val uid = authDetails.uid + return getApplicationStatus.execute(uid) + } } diff --git a/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt new file mode 100644 index 0000000..ca28c47 --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt @@ -0,0 +1,32 @@ +package land.leets.domain.application.presentation.dto + +import land.leets.domain.application.domain.Application +import land.leets.domain.application.type.ApplicationStatus + +data class ApplicationStatusResponse( + val id: Long, + val status: ApplicationStatus, + val interviewDay: String?, + val interviewTime: String?, +) { + companion object { + fun from( + application: Application, + ): ApplicationStatusResponse { + if (application.applicationStatus != ApplicationStatus.PASS_PAPER) { + return ApplicationStatusResponse( + id = application.id!!, + status = application.applicationStatus, + interviewDay = null, + interviewTime = null, + ) + } + return ApplicationStatusResponse( + id = application.id!!, + status = application.applicationStatus, + interviewDay = application.interviewDay, + interviewTime = application.interviewTime, + ) + } + } +} From 697d92238d0a43324ca66946d55e338a505c3976 Mon Sep 17 00:00:00 2001 From: Jeongwan Noh Date: Tue, 30 Dec 2025 12:51:27 +0900 Subject: [PATCH 19/21] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetApplicationStatus.kt | 8 +++++++ .../usecase/GetApplicationStatusImpl.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt create mode 100644 src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt new file mode 100644 index 0000000..2298bcb --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt @@ -0,0 +1,8 @@ +package land.leets.domain.application.usecase + +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse +import java.util.UUID + +interface GetApplicationStatus { + fun execute(uid: UUID): ApplicationStatusResponse +} \ No newline at end of file diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt new file mode 100644 index 0000000..f567d1f --- /dev/null +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt @@ -0,0 +1,21 @@ +package land.leets.domain.application.usecase + +import land.leets.domain.application.domain.repository.ApplicationRepository +import land.leets.domain.application.exception.ApplicationNotFoundException +import land.leets.domain.application.presentation.dto.ApplicationStatusResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +@Transactional(readOnly = true) +class GetApplicationStatusImpl( + private val applicationRepository: ApplicationRepository, +) : GetApplicationStatus { + override fun execute(uid: UUID): ApplicationStatusResponse { + val application = applicationRepository.findByUser_Id(uid) + ?: throw ApplicationNotFoundException() + + return ApplicationStatusResponse.from(application) + } +} \ No newline at end of file From 04047c1afd88cbee8f11116c335680ef30e5dfc8 Mon Sep 17 00:00:00 2001 From: Jeongwan Noh Date: Tue, 30 Dec 2025 12:51:48 +0900 Subject: [PATCH 20/21] =?UTF-8?q?test:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/GetApplicationStatusImplTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt diff --git a/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt new file mode 100644 index 0000000..7b676d3 --- /dev/null +++ b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt @@ -0,0 +1,82 @@ +package land.leets.domain.application.usecase + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import land.leets.domain.application.domain.Application +import land.leets.domain.application.domain.repository.ApplicationRepository +import land.leets.domain.application.exception.ApplicationNotFoundException +import land.leets.domain.application.type.ApplicationStatus +import java.util.UUID + +class GetApplicationStatusImplTest : DescribeSpec({ + + val applicationRepository = mockk() + val getApplicationStatus = GetApplicationStatusImpl(applicationRepository) + + val uid = UUID.randomUUID() + + val interviewDay = "2025년 3월 3일 (토)" + val interviewTime = "15:00" + + fun mockApplication( + id: Long = 1L, + status: ApplicationStatus, + day: String = interviewDay, + time: String = interviewTime + ): Application = mockk().also { application -> + every { application.id } returns id + every { application.applicationStatus } returns status + every { application.interviewDay } returns day + every { application.interviewTime } returns time + } + + describe("GetApplicationStatusImpl 유스케이스는") { + + context("지원서 상태 조회를 요청할 때") { + + it("지원서 상태가 PASS_PAPER이면 인터뷰 정보를 포함하여 반환한다") { + val application = mockApplication(status = ApplicationStatus.PASS_PAPER) + every { applicationRepository.findByUser_Id(uid) } returns application + + val result = getApplicationStatus.execute(uid) + + result.id shouldBe 1L + result.status shouldBe ApplicationStatus.PASS_PAPER + result.interviewDay shouldBe interviewDay + result.interviewTime shouldBe interviewTime + } + + it("PASS_PAPER이 아닌 상태들은 인터뷰 정보가 null이어야 한다") { + val nonInterviewStatuses = listOf( + ApplicationStatus.PENDING, + ApplicationStatus.FAIL_PAPER, + ApplicationStatus.PASS, + ApplicationStatus.FAIL, + ) + + nonInterviewStatuses.forEach { status -> + val application = mockApplication(status = status) + every { applicationRepository.findByUser_Id(uid) } returns application + + val result = getApplicationStatus.execute(uid) + + result.id shouldBe 1L + result.status shouldBe status + result.interviewDay shouldBe null + result.interviewTime shouldBe null + } + } + + it("지원서가 존재하지 않으면 ApplicationNotFoundException을 던진다") { + every { applicationRepository.findByUser_Id(uid) } returns null + + shouldThrow { + getApplicationStatus.execute(uid) + } + } + } + } +}) From da6f028dcb5dbee4b33611127f2c91f36a933fc5 Mon Sep 17 00:00:00 2001 From: Jeongwan Noh Date: Wed, 31 Dec 2025 18:11:35 +0900 Subject: [PATCH 21/21] =?UTF-8?q?fix:=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EB=B7=B0=20=EC=9D=BC=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=A0=84=EB=8B=AC=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/ApplicationController.kt | 1 - .../dto/ApplicationStatusResponse.kt | 21 ++++++---- .../usecase/GetApplicationStatus.kt | 2 +- .../usecase/GetApplicationStatusImpl.kt | 7 +++- .../usecase/GetApplicationStatusImplTest.kt | 41 +++++++++++++------ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt index cc3b4cb..d7d8c71 100644 --- a/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt +++ b/src/main/kotlin/land/leets/domain/application/presentation/ApplicationController.kt @@ -17,7 +17,6 @@ import land.leets.domain.auth.AuthDetails import land.leets.global.error.ErrorResponse import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import java.util.* @RestController @RequestMapping("/application") diff --git a/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt index ca28c47..ad3b426 100644 --- a/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt +++ b/src/main/kotlin/land/leets/domain/application/presentation/dto/ApplicationStatusResponse.kt @@ -2,30 +2,37 @@ package land.leets.domain.application.presentation.dto import land.leets.domain.application.domain.Application import land.leets.domain.application.type.ApplicationStatus +import land.leets.domain.interview.domain.Interview +import land.leets.domain.interview.type.HasInterview +import java.time.LocalDateTime data class ApplicationStatusResponse( val id: Long, val status: ApplicationStatus, - val interviewDay: String?, - val interviewTime: String?, + val hasInterview: HasInterview?, + val interviewDate: LocalDateTime?, + val interviewPlace: String?, ) { companion object { - fun from( + fun of( application: Application, + interview: Interview?, ): ApplicationStatusResponse { if (application.applicationStatus != ApplicationStatus.PASS_PAPER) { return ApplicationStatusResponse( id = application.id!!, status = application.applicationStatus, - interviewDay = null, - interviewTime = null, + hasInterview = null, + interviewDate = null, + interviewPlace = null, ) } return ApplicationStatusResponse( id = application.id!!, status = application.applicationStatus, - interviewDay = application.interviewDay, - interviewTime = application.interviewTime, + hasInterview = interview!!.hasInterview, + interviewDate = interview.fixedInterviewDate, + interviewPlace = interview.place, ) } } diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt index 2298bcb..488d57c 100644 --- a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatus.kt @@ -5,4 +5,4 @@ import java.util.UUID interface GetApplicationStatus { fun execute(uid: UUID): ApplicationStatusResponse -} \ No newline at end of file +} diff --git a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt index f567d1f..b4788be 100644 --- a/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt +++ b/src/main/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImpl.kt @@ -3,6 +3,7 @@ package land.leets.domain.application.usecase import land.leets.domain.application.domain.repository.ApplicationRepository import land.leets.domain.application.exception.ApplicationNotFoundException import land.leets.domain.application.presentation.dto.ApplicationStatusResponse +import land.leets.domain.interview.domain.repository.InterviewRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -11,11 +12,13 @@ import java.util.UUID @Transactional(readOnly = true) class GetApplicationStatusImpl( private val applicationRepository: ApplicationRepository, + private val interviewRepository: InterviewRepository, ) : GetApplicationStatus { override fun execute(uid: UUID): ApplicationStatusResponse { val application = applicationRepository.findByUser_Id(uid) ?: throw ApplicationNotFoundException() + val interview = interviewRepository.findByApplication(application) - return ApplicationStatusResponse.from(application) + return ApplicationStatusResponse.of(application, interview) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt index 7b676d3..7afbe7f 100644 --- a/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt +++ b/src/test/kotlin/land/leets/domain/application/usecase/GetApplicationStatusImplTest.kt @@ -9,28 +9,37 @@ import land.leets.domain.application.domain.Application import land.leets.domain.application.domain.repository.ApplicationRepository import land.leets.domain.application.exception.ApplicationNotFoundException import land.leets.domain.application.type.ApplicationStatus +import land.leets.domain.interview.domain.Interview +import land.leets.domain.interview.domain.repository.InterviewRepository +import land.leets.domain.interview.type.HasInterview +import java.time.LocalDateTime import java.util.UUID class GetApplicationStatusImplTest : DescribeSpec({ val applicationRepository = mockk() - val getApplicationStatus = GetApplicationStatusImpl(applicationRepository) + val interviewRepository = mockk() + val getApplicationStatus = GetApplicationStatusImpl(applicationRepository, interviewRepository) val uid = UUID.randomUUID() - val interviewDay = "2025년 3월 3일 (토)" - val interviewTime = "15:00" + val interviewDate: LocalDateTime = LocalDateTime.of(2026, 3, 14, 14, 0) + val interviewPlace = "전자정보도서관 1층 스터디룸 A" + val applicationId = 1L fun mockApplication( - id: Long = 1L, status: ApplicationStatus, - day: String = interviewDay, - time: String = interviewTime ): Application = mockk().also { application -> - every { application.id } returns id + every { application.id } returns applicationId every { application.applicationStatus } returns status - every { application.interviewDay } returns day - every { application.interviewTime } returns time + } + + fun mockInterview( + hasInterview: HasInterview = HasInterview.PENDING, + ): Interview = mockk().also { interview -> + every { interview.hasInterview } returns hasInterview + every { interview.fixedInterviewDate } returns interviewDate + every { interview.place } returns interviewPlace } describe("GetApplicationStatusImpl 유스케이스는") { @@ -39,14 +48,17 @@ class GetApplicationStatusImplTest : DescribeSpec({ it("지원서 상태가 PASS_PAPER이면 인터뷰 정보를 포함하여 반환한다") { val application = mockApplication(status = ApplicationStatus.PASS_PAPER) + val interview = mockInterview() every { applicationRepository.findByUser_Id(uid) } returns application + every { interviewRepository.findByApplication(application) } returns interview val result = getApplicationStatus.execute(uid) result.id shouldBe 1L result.status shouldBe ApplicationStatus.PASS_PAPER - result.interviewDay shouldBe interviewDay - result.interviewTime shouldBe interviewTime + result.hasInterview shouldBe HasInterview.PENDING + result.interviewDate shouldBe interviewDate + result.interviewPlace shouldBe interviewPlace } it("PASS_PAPER이 아닌 상태들은 인터뷰 정보가 null이어야 한다") { @@ -59,14 +71,17 @@ class GetApplicationStatusImplTest : DescribeSpec({ nonInterviewStatuses.forEach { status -> val application = mockApplication(status = status) + val interview = mockInterview() every { applicationRepository.findByUser_Id(uid) } returns application + every { interviewRepository.findByApplication(application) } returns interview val result = getApplicationStatus.execute(uid) result.id shouldBe 1L result.status shouldBe status - result.interviewDay shouldBe null - result.interviewTime shouldBe null + result.hasInterview shouldBe null + result.interviewDate shouldBe null + result.interviewPlace shouldBe null } }