Skip to content
This repository was archived by the owner on Jul 7, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .junie/guidelines.md
Original file line number Diff line number Diff line change
@@ -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<Interface>()` 형태로 모의 객체를 생성합니다.
* `every { ... } returns ...` 구문을 사용하여 모의 객체의 동작을 정의합니다.
* 필요한 경우 `verify { ... }` 구문을 사용하여 모의 객체의 메서드 호출을 검증합니다.

3. **테스트 시나리오 작성**
* given: 테스트 전제 조건을 설정합니다.
* when: 테스트할 동작을 실행합니다.
* then: 결과를 검증합니다.
* 테스트 설명은 한글로 작성하여 가독성을 높입니다.

4. **예외 테스트**
* `shouldThrow<ExceptionType> { ... }` 구문을 사용하여 예외 발생을 검증합니다.

### 테스트 실행
* 개별 테스트는 독립적으로 실행 가능해야 합니다.
* 테스트 간 의존성이 없어야 합니다.
* 테스트는 빠르게 실행되어야 합니다.

### 코드 품질
* 테스트 코드도 프로덕션 코드와 동일한 품질 기준을 적용합니다.
* 테스트 코드는 명확하고 이해하기 쉽게 작성해야 합니다.
* 중복 코드는 적절한 헬퍼 메서드나 픽스처를 사용하여 제거합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -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("유효하지 않은 소셜 로그인 제공자입니다.")
}
}
}


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

@Bean
@Qualifier("googleWebClient")
fun googleWebClient(): WebClient =
WebClient
.builder()
.baseUrl("https://oauth2.googleapis.com")
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.build()
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OAuthException.OAuthRetrieveFailedException> {
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<OAuthException.OAuthRetrieveFailedException> {
googleOAuthRetrieveHandler.getOAuthInfo(request)
}
}
}
}
})
Loading