diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 00000000..79a8ee67 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,58 @@ +# 프로젝트 가이드라인 + +## 프로젝트 개요 +Lettering-Backend는 모듈화된 아키텍처를 사용하는 Kotlin 기반 백엔드 프로젝트입니다. 이 프로젝트는 다음과 같은 모듈로 구성되어 있습니다: + +* **Application-Module**: 비즈니스 로직과 유스케이스를 포함합니다. +* **Bootstrap-Module**: 애플리케이션의 진입점과 API 엔드포인트를 정의합니다. +* **Common-Module**: 공통 유틸리티와 기능을 제공합니다. +* **Domain-Module**: 도메인 엔티티와 비즈니스 규칙을 정의합니다. +* **Infrastructure-Module**: 외부 시스템과의 통합을 담당합니다. + * AWS: AWS 서비스 통합 + * Client: 외부 API 클라이언트 + * Event: 이벤트 처리 + * Persistence: 데이터 저장소 관련 코드 + * Security: 인증 및 권한 부여 + +## 테스트 가이드라인 + +### 기본 원칙 +1. **모킹을 기본으로 슬라이스 테스트를 진행한다.** + * 각 서비스나 컴포넌트는 독립적으로 테스트되어야 합니다. + * 외부 의존성은 mockk를 사용하여 모킹해야 합니다. + * 테스트는 특정 기능 단위(슬라이스)에 집중해야 합니다. + +2. **kotest를 활용한 테스트 검증을 진행해야한다.** + * 모든 테스트는 kotest 프레임워크를 사용하여 작성해야 합니다. + * BehaviorSpec 스타일(given/when/then)을 사용하여 테스트를 구조화합니다. + * kotest의 assertion 라이브러리(shouldBe, shouldNotBeNull 등)를 사용하여 결과를 검증합니다. + +### 테스트 작성 방법 +1. **테스트 클래스 구조** + * 테스트 클래스는 테스트 대상 클래스 이름에 'Test'를 붙여 명명합니다. (예: `SocialLoginServiceTest`) + * BehaviorSpec을 상속받아 테스트를 구현합니다. + +2. **모킹 방법** + * mockk 라이브러리를 사용하여 의존성을 모킹합니다. + * `mockk()` 형태로 모의 객체를 생성합니다. + * `every { ... } returns ...` 구문을 사용하여 모의 객체의 동작을 정의합니다. + * 필요한 경우 `verify { ... }` 구문을 사용하여 모의 객체의 메서드 호출을 검증합니다. + +3. **테스트 시나리오 작성** + * given: 테스트 전제 조건을 설정합니다. + * when: 테스트할 동작을 실행합니다. + * then: 결과를 검증합니다. + * 테스트 설명은 한글로 작성하여 가독성을 높입니다. + +4. **예외 테스트** + * `shouldThrow { ... }` 구문을 사용하여 예외 발생을 검증합니다. + +### 테스트 실행 +* 개별 테스트는 독립적으로 실행 가능해야 합니다. +* 테스트 간 의존성이 없어야 합니다. +* 테스트는 빠르게 실행되어야 합니다. + +### 코드 품질 +* 테스트 코드도 프로덕션 코드와 동일한 품질 기준을 적용합니다. +* 테스트 코드는 명확하고 이해하기 쉽게 작성해야 합니다. +* 중복 코드는 적절한 헬퍼 메서드나 픽스처를 사용하여 제거합니다. diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt index 8de56f26..3580b11a 100644 --- a/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt @@ -3,16 +3,16 @@ package com.asap.domain.user.enums import com.asap.common.exception.DefaultException enum class SocialLoginProvider { - KAKAO; + KAKAO, + GOOGLE, + NAVER, + ; - companion object{ - fun parse(value: String): SocialLoginProvider { - return when (value) { + companion object { + fun parse(value: String): SocialLoginProvider = + when (value) { entries.firstOrNull { it.name == value }?.name -> valueOf(value) else -> throw DefaultException.InvalidArgumentException("유효하지 않은 소셜 로그인 제공자입니다.") } - } } - - -} \ No newline at end of file +} diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt index 1fe85121..9c65a731 100644 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt @@ -8,13 +8,21 @@ import org.springframework.web.reactive.function.client.WebClient @Configuration class OAuthWebClientConfig { - @Bean @Qualifier("kakaoWebClient") - fun kakaoWebClient(): WebClient { - return WebClient.builder() + fun kakaoWebClient(): WebClient = + WebClient + .builder() .baseUrl("https://kapi.kakao.com") .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) .build() - } -} \ No newline at end of file + + @Bean + @Qualifier("googleWebClient") + fun googleWebClient(): WebClient = + WebClient + .builder() + .baseUrl("https://oauth2.googleapis.com") + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .build() +} diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt new file mode 100644 index 00000000..bea04aed --- /dev/null +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandler.kt @@ -0,0 +1,44 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import com.asap.client.oauth.exception.OAuthException +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +@Component +class GoogleOAuthRetrieveHandler( + @Qualifier("googleWebClient") private val googleWebClient: WebClient, +) : OAuthRetrieveHandler { + override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse { + val googleUserInfo = + googleWebClient + .get() + .uri("/userinfo/v2/me") + .header("Authorization", "Bearer ${request.accessToken}") + .retrieve() + .onStatus({ it.isError }, { + throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.") + }) + .bodyToMono(GoogleUserInfo::class.java) + .block() + + if (googleUserInfo == null) { + throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.") + } + + return OAuthRetrieveHandler.OAuthResponse( + username = googleUserInfo.name, + socialId = googleUserInfo.id, + email = googleUserInfo.email, + profileImage = googleUserInfo.picture, + ) + } + + data class GoogleUserInfo( + val email: String, + val name: String, + val id: String, + val picture: String, + ) +} diff --git a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt index c28ca8a0..cc16d227 100644 --- a/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt +++ b/Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt @@ -11,25 +11,30 @@ import org.springframework.web.reactive.function.client.WebClient class KakaoOAuthRetrieveHandler( @Qualifier("kakaoWebClient") private val kakaoWebClient: WebClient, ) : OAuthRetrieveHandler { - override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse = - kakaoWebClient - .get() - .uri("/v2/user/me") - .header("Authorization", "Bearer ${request.accessToken}") - .retrieve() - .onStatus({ it.isError }, { - throw OAuthException.OAuthRetrieveFailedException("카카오 사용자 정보를 가져오는데 실패했습니다.") - }) - .bodyToMono(KakaoUserInfo::class.java) - .block() - ?.let { - OAuthRetrieveHandler.OAuthResponse( - username = it.properties.nickname, - socialId = it.id, - profileImage = it.properties.profileImage, - email = it.kakaoAccount.email, - ) - } ?: throw OAuthException.OAuthRetrieveFailedException("카카오 사용자 정보를 가져오는데 실패했습니다.") + override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse { + val kakaoUserInfo = + kakaoWebClient + .get() + .uri("/v2/user/me") + .header("Authorization", "Bearer ${request.accessToken}") + .retrieve() + .onStatus({ it.isError }, { + throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.") + }) + .bodyToMono(KakaoUserInfo::class.java) + .block() + + if (kakaoUserInfo == null) { + throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.") + } + + return OAuthRetrieveHandler.OAuthResponse( + username = kakaoUserInfo.properties.nickname, + socialId = kakaoUserInfo.id, + profileImage = kakaoUserInfo.properties.profileImage, + email = kakaoUserInfo.kakaoAccount.email, + ) + } data class KakaoUserInfo( val id: String, diff --git a/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandlerTest.kt b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandlerTest.kt new file mode 100644 index 00000000..7a6a1c47 --- /dev/null +++ b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/GoogleOAuthRetrieveHandlerTest.kt @@ -0,0 +1,102 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import com.asap.client.oauth.exception.OAuthException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient + +class GoogleOAuthRetrieveHandlerTest : + BehaviorSpec({ + var mockWebServer: MockWebServer = MockWebServer() + var googleWebClient: WebClient = + WebClient + .builder() + .baseUrl(mockWebServer.url("/").toString()) + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .build() + var googleOAuthRetrieveHandler: GoogleOAuthRetrieveHandler = GoogleOAuthRetrieveHandler(googleWebClient) + + given("OAuth 요청이 성공적으로 처리되었을 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + val responseBody = + """ + { + "email": "test@example.com", + "name": "Test User", + "id": "12345", + "picture": "https://example.com/profile.jpg" + } + """.trimIndent() + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(responseBody), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + val response = googleOAuthRetrieveHandler.getOAuthInfo(request) + + then("올바른 OAuthResponse를 반환해야 한다") { + response.username shouldBe "Test User" + response.socialId shouldBe "12345" + response.email shouldBe "test@example.com" + response.profileImage shouldBe "https://example.com/profile.jpg" + + // 요청 검증 + val recordedRequest = mockWebServer.takeRequest() + recordedRequest.path shouldBe "/userinfo/v2/me" + recordedRequest.getHeader("Authorization") shouldBe "Bearer test-access-token" + } + } + } + + given("API가 오류를 반환할 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\": \"invalid_token\"}"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + val exception = + shouldThrow { + googleOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + + given("응답이 null일 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("null"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + shouldThrow { + googleOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + }) diff --git a/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandlerTest.kt b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandlerTest.kt new file mode 100644 index 00000000..682cea14 --- /dev/null +++ b/Infrastructure-Module/Client/src/test/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandlerTest.kt @@ -0,0 +1,109 @@ +package com.asap.client.oauth.platform + +import com.asap.client.oauth.OAuthRetrieveHandler +import com.asap.client.oauth.exception.OAuthException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient + +class KakaoOAuthRetrieveHandlerTest : + BehaviorSpec({ + + var mockWebServer = + MockWebServer().also { + it.start() + } + var kakaoWebClient: WebClient = + WebClient + .builder() + .baseUrl(mockWebServer.url("/").toString()) + .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .build() + var kakaoOAuthRetrieveHandler = KakaoOAuthRetrieveHandler(kakaoWebClient) + + given("OAuth 요청이 성공적으로 처리되었을 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + val responseBody = + """ + { + "id": "12345", + "properties": { + "nickname": "Test User", + "profile_image": "https://example.com/profile.jpg" + }, + "kakao_account": { + "email": "test@example.com" + } + } + """.trimIndent() + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(responseBody), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + val response = kakaoOAuthRetrieveHandler.getOAuthInfo(request) + + then("올바른 OAuthResponse를 반환해야 한다") { + response.username shouldBe "Test User" + response.socialId shouldBe "12345" + response.email shouldBe "test@example.com" + response.profileImage shouldBe "https://example.com/profile.jpg" + + // 요청 검증 + val recordedRequest = mockWebServer.takeRequest() + recordedRequest.path shouldBe "/v2/user/me" + recordedRequest.getHeader("Authorization") shouldBe "Bearer test-access-token" + } + } + } + + given("API가 오류를 반환할 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\": \"invalid_token\"}"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + shouldThrow { + kakaoOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + + given("응답이 null일 때") { + val accessToken = "test-access-token" + val request = OAuthRetrieveHandler.OAuthRequest(accessToken) + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("null"), + ) + + `when`("getOAuthInfo 메소드를 호출하면") { + then("OAuthRetrieveFailedException이 발생해야 한다") { + shouldThrow { + kakaoOAuthRetrieveHandler.getOAuthInfo(request) + } + } + } + } + }) diff --git a/Infrastructure-Module/Client/src/testFixtures/kotlin/com/asap/client/TestWebClientConfig.kt b/Infrastructure-Module/Client/src/testFixtures/kotlin/com/asap/client/TestWebClientConfig.kt index 69283f72..dfc8a6b6 100644 --- a/Infrastructure-Module/Client/src/testFixtures/kotlin/com/asap/client/TestWebClientConfig.kt +++ b/Infrastructure-Module/Client/src/testFixtures/kotlin/com/asap/client/TestWebClientConfig.kt @@ -1,6 +1,5 @@ package com.asap.client - import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.springframework.beans.factory.annotation.Qualifier @@ -12,12 +11,12 @@ import org.springframework.web.reactive.function.client.WebClient @TestConfiguration class TestWebClientConfig { - @Bean fun kakaoMockWebServer(): MockServer { - val mockWebServer = MockWebServer().apply { - url("/v2/user/me") - } + val mockWebServer = + MockWebServer().apply { + url("/v2/user/me") + } return object : MockServer { override fun start() { mockWebServer.start() @@ -30,7 +29,7 @@ class TestWebClientConfig { .setBody(response.body) .also { response.headers.forEach { (key, value) -> it.addHeader(key, value) } - } + }, ) } @@ -38,22 +37,17 @@ class TestWebClientConfig { mockWebServer.shutdown() } - override fun url(baseUrl: String): String { - return mockWebServer.url(baseUrl).toString() - } - + override fun url(baseUrl: String): String = mockWebServer.url(baseUrl).toString() } } @Bean @Qualifier("kakaoWebClient") @Primary - fun mockKakaoWebClient(kakaoMockWebServer: MockServer): WebClient { - return WebClient.builder() + fun mockKakaoWebClient(kakaoMockWebServer: MockServer): WebClient = + WebClient + .builder() .baseUrl(kakaoMockWebServer.url("/")) .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) .build() - } - - -} \ No newline at end of file +}