diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt index 81311c3..a7ea7c3 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -7,11 +7,9 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/internal-api/v1/members") class InternalMemberController( private val memberService: MemberService, @Value("\${app.internal.secret}") private val internalSecret: String, @@ -21,7 +19,7 @@ class InternalMemberController( private val log = LoggerFactory.getLogger(InternalMemberController::class.java) } - @GetMapping("/{userId}") + @GetMapping("/internal-api/v1/members/{userId}") fun getUserInfo( @PathVariable userId: String, @RequestHeader("X-Internal-Secret") secret: String, @@ -37,6 +35,7 @@ class InternalMemberController( userId = userInfo.userId, email = userInfo.email, nickname = userInfo.nickname, + phoneNumber = userInfo.phoneNumber, )) } @@ -44,5 +43,6 @@ class InternalMemberController( val userId: String, val email: String, val nickname: String, + val phoneNumber: String?, ) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt index 90535de..d01baf6 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt @@ -55,6 +55,7 @@ class MemberController( userId = result.userId, nickname = result.nickname, email = result.email, + phoneNumber = result.phoneNumber, linkedProviders = result.providers ) return CommonResponse.success(message = "회원 정보 조회 성공", data = resp) diff --git a/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt b/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt index 4f4f8f9..1bd01cc 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt @@ -7,5 +7,6 @@ data class UserInfoResponseDto( val userId: String, val nickname: String, val email: String, + val phoneNumber: String?, val linkedProviders: List ) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt index 6708123..7db10d4 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt @@ -56,13 +56,16 @@ class SocialLoginMemberProcessor( providerType )?.let { existingAuthProvider -> log.info { "기존 회원 발견: ${existingAuthProvider.member.opaqueId}" } - Pair(existingAuthProvider.member, false) + val existingMember = existingAuthProvider.member + updatePhoneNumberIfChanged(existingMember, oauthUser) + Pair(existingMember, false) } ?: run { log.info { "신규 회원 생성: ${oauthUser.email}" } val newMember = MemberEntity.createSocialMember( nickname = oauthUser.getNickname(), isEmailVerified = oauthUser.verifiedEmail, - primaryEmail = oauthUser.email + primaryEmail = oauthUser.email, + phoneNumber = oauthUser.phoneNumber ) val savedMember = memberRepository.save(newMember) log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } @@ -70,6 +73,18 @@ class SocialLoginMemberProcessor( } } + /** + * 소셜 제공자가 전달한 전화번호가 있으면 항상 최신 값으로 갱신합니다. + */ + private fun updatePhoneNumberIfChanged(member: MemberEntity, oauthUser: OAuthUser) { + val phoneNumber = oauthUser.phoneNumber + if (!phoneNumber.isNullOrBlank() && member.phoneNumber != phoneNumber) { + member.updatePhoneNumber(phoneNumber) + memberRepository.save(member) + log.info { "회원 전화번호 갱신 완료: ${member.opaqueId}" } + } + } + private fun createOrUpdateAuthProvider( member: MemberEntity, oauthUser: OAuthUser, diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index fcda734..8706141 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -23,6 +23,7 @@ class MemberService( val userId: String, val nickname: String, val email: String, + val phoneNumber: String?, val providers: List, ) @@ -65,6 +66,7 @@ class MemberService( userId = member.opaqueId, nickname = member.nickname, email = email!!, + phoneNumber = member.phoneNumber, providers = providers ) } diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt index 25477c9..1454c53 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt @@ -21,7 +21,7 @@ open class MemberEntity protected constructor( val primaryEmail: String? = null, @Column(name = "phone_number", length = 20, nullable = true) - val phoneNumber: String? = null, + var phoneNumber: String? = null, @Column(name = "opaque_id", nullable = false, unique = true, length = 36) val opaqueId: String, @@ -69,6 +69,7 @@ open class MemberEntity protected constructor( nickname: String, isEmailVerified: Boolean = true, primaryEmail: String, + phoneNumber: String? = null, ): MemberEntity { require(nickname.isNotBlank()) { "닉네임은 필수입니다" } require(nickname.length <= 100) { "닉네임은 100자를 초과할 수 없습니다" } @@ -77,7 +78,8 @@ open class MemberEntity protected constructor( opaqueId = UuidCreator.getTimeOrdered().toString(), nickname = nickname.trim(), isEmailVerified = isEmailVerified, - primaryEmail = primaryEmail + primaryEmail = primaryEmail, + phoneNumber = phoneNumber ) } } @@ -89,6 +91,13 @@ open class MemberEntity protected constructor( this.isEmailVerified = true } + /** + * 전화번호 업데이트 + */ + fun updatePhoneNumber(phoneNumber: String) { + this.phoneNumber = phoneNumber + } + /** * 최근 로그인 시간 업데이트 */ diff --git a/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt b/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt index 472b72a..5abb1b2 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt @@ -14,6 +14,7 @@ data class OAuthUser( val verifiedEmail: Boolean, val name: String?, val givenName: String? = null, + val phoneNumber: String? = null, val providerType: ProviderType ) { /** diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt index 29b4b5c..797a2e6 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt @@ -160,6 +160,7 @@ class NaverOAuthClient( verifiedEmail = naverUserInfo.response.email != null, name = naverUserInfo.response.name, givenName = naverUserInfo.response.nickname, // 네이버는 givenName이 없으므로 nickname 사용 + phoneNumber = naverUserInfo.response.getNormalizedMobile(), providerType = ProviderType.NAVER ) } diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt index 2a329e4..5d4cbc2 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt @@ -70,4 +70,12 @@ data class NaverUserInfo( * Naver 제공자 ID를 반환합니다. */ fun getProviderId(): String = id + + /** + * mobile 값을 숫자만 남긴 형태로 정규화하여 반환합니다. (예: "010-1234-5678" -> "01012345678") + */ + fun getNormalizedMobile(): String? { + val digits = mobile?.filter { it.isDigit() } + return if (digits.isNullOrBlank()) null else digits + } } \ No newline at end of file diff --git a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt index 5307004..c8d9582 100644 --- a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt @@ -8,6 +8,7 @@ import com.wq.auth.api.domain.auth.AuthProviderRepository import com.wq.auth.api.domain.auth.AuthService import com.wq.auth.api.domain.auth.MemberConnector import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberStatsService import com.wq.auth.api.domain.auth.RefreshTokenRepository import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity import com.wq.auth.api.domain.auth.error.AuthException @@ -35,6 +36,7 @@ class AuthServiceTest : DescribeSpec({ lateinit var jwtProvider: JwtProvider lateinit var nicknameGenerator: NicknameGenerator lateinit var memberConnector: MemberConnector + lateinit var memberStatsService: MemberStatsService beforeEach { authProviderRepository = mock() @@ -44,6 +46,7 @@ class AuthServiceTest : DescribeSpec({ jwtProvider = mock() nicknameGenerator = mock() memberConnector = mock() + memberStatsService = mock() authService = AuthService( authEmailService = authEmailService, @@ -53,6 +56,7 @@ class AuthServiceTest : DescribeSpec({ jwtProvider = jwtProvider, nicknameGenerator = nicknameGenerator, memberConnector = memberConnector, + memberStatsService = memberStatsService, ) } @@ -79,7 +83,7 @@ class AuthServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -94,7 +98,7 @@ class AuthServiceTest : DescribeSpec({ result.refreshToken shouldBe refreshToken verify(authProviderRepository).findByEmailAndProviderType(email, ProviderType.EMAIL) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).save(any()) } @@ -119,7 +123,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) whenever(jwtProvider.getRefreshTokenExpiredAt(refreshToken)).thenReturn(futureTime) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -137,7 +141,7 @@ class AuthServiceTest : DescribeSpec({ verify(jwtProvider, times(1)).getJti(refreshToken) verify(jwtProvider, times(1)).getOpaqueId(refreshToken) verify(refreshTokenRepository, times(1)).findActiveByOpaqueIdAndJti(opaqueId, jti) - verify(jwtProvider, times(1)).createAccessToken(any(), any(), any()) + verify(jwtProvider, times(1)).createAccessToken(any(), any()) verify(jwtProvider, times(1)).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).softDeleteByOpaqueIdAndJti(any(), any(), any()) verify(refreshTokenRepository, times(1)).save(any()) @@ -215,7 +219,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("new-access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-refresh-token") whenever(jwtProvider.getJti("new-refresh-token")).thenReturn("new-jti") @@ -243,7 +247,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("new-access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-refresh-token") whenever(jwtProvider.getJti("new-refresh-token")).thenReturn("new-jti") @@ -274,7 +278,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -314,7 +318,7 @@ class AuthServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -330,7 +334,7 @@ class AuthServiceTest : DescribeSpec({ verify(authProviderRepository).findByEmailAndProviderType(email, ProviderType.EMAIL) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) verify(refreshTokenRepository).save(any()) } @@ -353,7 +357,7 @@ class AuthServiceTest : DescribeSpec({ whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) whenever(refreshTokenRepository.findActiveByMemberAndDeviceId(mockMember, deviceId)).thenReturn(existingRefreshToken) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh-token") whenever(jwtProvider.getJti("refresh-token")).thenReturn("jti") @@ -388,7 +392,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -433,7 +437,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -452,7 +456,7 @@ class AuthServiceTest : DescribeSpec({ verify(memberRepository).existsByNickname(nickname) verify(memberRepository).save(any()) verify(authProviderRepository).save(any()) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) } @@ -478,7 +482,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -497,7 +501,7 @@ class AuthServiceTest : DescribeSpec({ verify(memberRepository).existsByNickname(nickname) verify(memberRepository).save(any()) verify(authProviderRepository).save(any()) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) } @@ -526,7 +530,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(uniqueNickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn("jti") @@ -564,7 +568,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(uniqueNickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh") whenever(jwtProvider.getJti("refresh")).thenReturn("jti") @@ -614,7 +618,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(memberCaptor.capture())).thenAnswer { mockMember } whenever(authProviderRepository.save(providerCaptor.capture())).thenAnswer { it.arguments[0] as AuthProviderEntity } - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh") whenever(jwtProvider.getJti("refresh")).thenReturn("jti") @@ -717,7 +721,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -736,7 +740,7 @@ class AuthServiceTest : DescribeSpec({ verify(jwtProvider, times(1)).getJti(refreshToken) verify(jwtProvider, times(1)).getOpaqueId(refreshToken) verify(refreshTokenRepository, times(1)).findActiveByOpaqueIdAndJti(opaqueId, jti) - verify(jwtProvider, times(1)).createAccessToken(any(), any(), any()) + verify(jwtProvider, times(1)).createAccessToken(any(), any()) verify(jwtProvider, times(1)).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).softDeleteByOpaqueIdAndJti(any(), any(), any()) verify(refreshTokenRepository, times(1)).save(any()) diff --git a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt index d67c12f..7f9a4e0 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt @@ -1,16 +1,30 @@ package com.wq.auth.unit import com.wq.auth.security.jwt.JwtProperties -import io.kotest.core.spec.style.FunSpec -import io.kotest.extensions.spring.SpringExtension -import io.kotest.matchers.shouldBe +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.TestPropertySource import java.time.Duration -@SpringBootTest +/** + * JwtProperties 바인딩 테스트 + * Kotest-extensions-spring 이 Kotest 6.x 를 지원하지 않으므로 + * JUnit 5 기반 Spring 테스트로 작성합니다. + */ +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:jwt-props-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "INTERNAL_API_SECRET=test-internal-secret" + ] +) @ConfigurationPropertiesScan @TestPropertySource(properties = [ // 32바이트(256bit) Base64 시크릿 예시 @@ -18,18 +32,15 @@ import java.time.Duration "jwt.access-exp=15m", "jwt.refresh-exp=14d" ]) -class JwtPropertiesBindingTest : FunSpec() { - - override fun extensions() = listOf(SpringExtension) +class JwtPropertiesBindingTest { @Autowired lateinit var props: JwtProperties - init { - test("JwtProperties 가 yml 값으로 정상 바인딩된다") { - props.secret shouldBe "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE===" - props.accessExp shouldBe Duration.ofMinutes(15) - props.refreshExp shouldBe Duration.ofDays(14) - } + @Test + fun `JwtProperties 가 yml 값으로 정상 바인딩된다`() { + assertThat(props.secret).isEqualTo("MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE===") + assertThat(props.accessExp).isEqualTo(Duration.ofMinutes(15)) + assertThat(props.refreshExp).isEqualTo(Duration.ofDays(14)) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt index 478d369..ab0fc59 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt @@ -1,6 +1,5 @@ package com.wq.auth.unit -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.JwtProperties import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException @@ -33,15 +32,14 @@ class JwtProviderTest : StringSpec({ "간소화된 AccessToken을 발급하면 opaqueId 파싱이 정상 동작한다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val token = provider.createAccessToken(opaqueId, Role.MEMBER) + val token = provider.createAccessToken(opaqueId) provider.getOpaqueId(token) shouldBe opaqueId - provider.getRole(token) shouldBe Role.MEMBER } - "간소화된 AccessToken에 role claim이 실제로 들어간다" { + "AccessToken에 extraClaims가 실제로 들어간다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val token = provider.createAccessToken(opaqueId, Role.ADMIN) - + val token = provider.createAccessToken(opaqueId, mapOf("role" to "ADMIN")) + val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(props.secret)) val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload claims["role"] shouldBe "ADMIN" @@ -54,7 +52,7 @@ class JwtProviderTest : StringSpec({ val providerWithAnotherKey = JwtProvider( JwtProperties(secret = keyB, accessExp = Duration.ofMinutes(5), refreshExp = Duration.ofDays(14)) ) - val tokenSignedByB = providerWithAnotherKey.createAccessToken("550e8400-e29b-41d4-a716-446655440000", Role.MEMBER) + val tokenSignedByB = providerWithAnotherKey.createAccessToken("550e8400-e29b-41d4-a716-446655440000") val ex = shouldThrow { provider.validateOrThrow(tokenSignedByB) @@ -71,7 +69,7 @@ class JwtProviderTest : StringSpec({ ) val shortExpProvider = JwtProvider(shortExpProps) - val token = shortExpProvider.createAccessToken("550e8400-e29b-41d4-a716-446655440000", Role.MEMBER) + val token = shortExpProvider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") Thread.sleep(200) // 100ms 대기 val ex = shouldThrow { shortExpProvider.validateOrThrow(token) } ex.jwtCode shouldBe JwtExceptionCode.EXPIRED @@ -117,8 +115,8 @@ class JwtProviderTest : StringSpec({ "토큰 생성 시 올바른 구조와 클레임이 포함된다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val role = Role.ADMIN - val token = provider.createAccessToken(opaqueId, role) + val roleName = "ADMIN" + val token = provider.createAccessToken(opaqueId, mapOf("role" to roleName)) // 토큰 구조 검증 (3개 세그먼트) val segments = token.split(".") @@ -129,13 +127,13 @@ class JwtProviderTest : StringSpec({ val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload claims.subject shouldBe opaqueId - claims["role"] shouldBe role.name + claims["role"] shouldBe roleName claims.issuedAt shouldNotBe null claims.expiration shouldNotBe null } "토큰 유효성 검증이 정상 동작한다" { - val validToken = provider.createAccessToken("test-user", Role.MEMBER) + val validToken = provider.createAccessToken("test-user") // 예외 없이 통과해야 함 provider.validateOrThrow(validToken) diff --git a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt index 22332fd..33fea5a 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt @@ -1,7 +1,6 @@ package com.wq.auth.unit import com.wq.auth.api.domain.member.entity.MemberEntity -import com.wq.auth.api.domain.member.entity.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -13,13 +12,11 @@ class MemberEntityTest : StringSpec({ "MemberEntity.create()를 통해 정상적으로 생성된다" { // Given & When val member = MemberEntity.create( - nickname = "테스트사용자", - role = Role.MEMBER + nickname = "테스트사용자" ) // Then member.nickname shouldBe "테스트사용자" - member.role shouldBe Role.MEMBER member.opaqueId shouldNotBe null UUID.fromString(member.opaqueId) // UUID 형식 검증 member.isEmailVerified shouldBe false @@ -62,17 +59,33 @@ class MemberEntityTest : StringSpec({ member.isEmailVerified shouldBe true } - "관리자 권한 확인이 정상 작동한다" { + "전화번호 업데이트가 정상 작동한다" { // Given - val adminMember = MemberEntity.create(nickname = "관리자", role = Role.ADMIN) - val regularMember = MemberEntity.create(nickname = "일반사용자", role = Role.MEMBER) + val member = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) - // When & Then - adminMember.isAdmin() shouldBe true - regularMember.isAdmin() shouldBe false + // When + member.updatePhoneNumber("01012345678") + + // Then + member.phoneNumber shouldBe "01012345678" + } + + "createSocialMember는 phoneNumber 없이도 생성된다" { + // Given & When + val member = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com" + ) + + // Then + member.phoneNumber shouldBe null } - /* + /* // 다음 코드는 컴파일 에러가 발생해야 함 (protected constructor) "외부에서 직접 생성자 호출 시도" { // 이 코드는 컴파일되지 않아야 함 diff --git a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt index ff7b814..def2e59 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt @@ -33,6 +33,7 @@ class MemberServiceTest : DescribeSpec({ val opaqueId = "validOpaqueId" val nickname = "testUser" val email = "test@email.com" + val phoneNumber = "01012345678" val mockMember = mock() val mockAuthProvider = mock() @@ -42,6 +43,7 @@ class MemberServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(mockAuthProvider.providerType).thenReturn(ProviderType.EMAIL) whenever(mockMember.primaryEmail).thenReturn(email) + whenever(mockMember.phoneNumber).thenReturn(phoneNumber) whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(mockMember)) whenever(authProviderRepository.findByMember(mockMember)).thenReturn(listOf(mockAuthProvider)) @@ -53,6 +55,7 @@ class MemberServiceTest : DescribeSpec({ result.userId shouldBe opaqueId result.nickname shouldBe nickname result.email shouldBe email + result.phoneNumber shouldBe phoneNumber verify(memberRepository).findByOpaqueId(opaqueId) verify(authProviderRepository).findByMember(mockMember) diff --git a/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt b/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt new file mode 100644 index 0000000..22c1604 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt @@ -0,0 +1,30 @@ +package com.wq.auth.unit + +import com.wq.auth.api.external.oauth.dto.NaverUserInfo +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class NaverUserInfoResponseTest : StringSpec({ + + fun naverUserInfo(mobile: String?) = NaverUserInfo( + id = "naver-id", + email = "test@naver.com", + mobile = mobile + ) + + "mobile의 하이픈이 제거되어 정규화된다" { + naverUserInfo("010-1234-5678").getNormalizedMobile() shouldBe "01012345678" + } + + "mobile이 null이면 null을 반환한다" { + naverUserInfo(null).getNormalizedMobile() shouldBe null + } + + "mobile이 빈 문자열이면 null을 반환한다" { + naverUserInfo("").getNormalizedMobile() shouldBe null + } + + "국가번호가 포함된 mobile도 숫자만 남는다" { + naverUserInfo("+82 10-1234-5678").getNormalizedMobile() shouldBe "821012345678" + } +}) diff --git a/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt b/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt new file mode 100644 index 0000000..f7db964 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt @@ -0,0 +1,118 @@ +package com.wq.auth.unit + +import com.wq.auth.api.domain.auth.AuthProviderRepository +import com.wq.auth.api.domain.auth.RefreshTokenRepository +import com.wq.auth.api.domain.auth.SocialLoginMemberProcessor +import com.wq.auth.api.domain.auth.entity.AuthProviderEntity +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberStatsService +import com.wq.auth.api.domain.member.entity.MemberEntity +import com.wq.auth.api.domain.oauth.OAuthUser +import com.wq.auth.security.jwt.JwtProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.mockito.kotlin.* + +class SocialLoginMemberProcessorTest : DescribeSpec({ + + lateinit var authProviderRepository: AuthProviderRepository + lateinit var memberRepository: MemberRepository + lateinit var jwtProvider: JwtProvider + lateinit var refreshTokenRepository: RefreshTokenRepository + lateinit var memberStatsService: MemberStatsService + lateinit var processor: SocialLoginMemberProcessor + + fun naverOAuthUser(phoneNumber: String?) = OAuthUser( + providerId = "naver-provider-id", + email = "test@naver.com", + verifiedEmail = true, + name = "테스트", + givenName = null, + phoneNumber = phoneNumber, + providerType = ProviderType.NAVER + ) + + beforeEach { + authProviderRepository = mock() + memberRepository = mock() + jwtProvider = mock() + refreshTokenRepository = mock() + memberStatsService = mock() + processor = SocialLoginMemberProcessor( + authProviderRepository, + memberRepository, + jwtProvider, + refreshTokenRepository, + memberStatsService, + ) + + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("access-token") + whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh-token") + whenever(jwtProvider.getJti(any())).thenReturn("jti") + whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaque-id") + whenever(memberRepository.save(any())).thenAnswer { it.arguments[0] } + } + + describe("전화번호 저장 및 갱신") { + + it("신규 회원 가입 시 phoneNumber가 저장된다") { + // given + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())).thenReturn(null) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())).thenReturn(null) + whenever(authProviderRepository.save(any())).thenAnswer { it.arguments[0] } + + // when + processor.processMemberAndIssueTokens(naverOAuthUser("01012345678"), ProviderType.NAVER) + + // then + val captor = argumentCaptor() + verify(memberRepository).save(captor.capture()) + captor.firstValue.phoneNumber shouldBe "01012345678" + } + + it("기존 회원 재로그인 시 phoneNumber가 항상 최신 값으로 갱신된다") { + // given + val existingMember = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) + val existingAuthProvider = mock() + whenever(existingAuthProvider.member).thenReturn(existingMember) + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + + // when + processor.processMemberAndIssueTokens(naverOAuthUser("01099998888"), ProviderType.NAVER) + + // then + existingMember.phoneNumber shouldBe "01099998888" + verify(memberRepository).save(existingMember) + } + + it("소셜 응답에 phoneNumber가 없으면 기존 값이 유지된다") { + // given + val existingMember = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) + val existingAuthProvider = mock() + whenever(existingAuthProvider.member).thenReturn(existingMember) + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + + // when + processor.processMemberAndIssueTokens(naverOAuthUser(null), ProviderType.NAVER) + + // then + existingMember.phoneNumber shouldBe "01011112222" + verify(memberRepository, never()).save(any()) + } + } +})