feat(member): 가입과 로그인 응답에 신규 회원 여부를 추가한다

This commit is contained in:
2026-11-03 10:54:00 +09:00
parent 22c3ee2ea7
commit af23d93551
3 changed files with 228 additions and 4 deletions
@@ -77,7 +77,7 @@ class MemberController(
) )
val message = messageSource.getMessage("member.signup.success", langContext.lang) val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse) return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = true))
} }
@PostMapping("/signup") @PostMapping("/signup")
@@ -96,7 +96,7 @@ class MemberController(
} }
val message = messageSource.getMessage("member.signup.success", langContext.lang) val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse) return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = true))
} }
@PostMapping("/login") @PostMapping("/login")
@@ -440,7 +440,7 @@ class MemberController(
} }
val message = messageSource.getMessage("member.signup.success", langContext.lang) val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse) return ApiResponse.ok(message = message, data = response.loginResponse.copy(isNew = response.isNew))
} }
private fun extractBearerToken(authHeader: String, provider: MemberProvider): String { private fun extractBearerToken(authHeader: String, provider: MemberProvider): String {
@@ -1,9 +1,14 @@
package kr.co.vividnext.sodalive.member.login package kr.co.vividnext.sodalive.member.login
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginResponse( data class LoginResponse(
val userId: Long, val userId: Long,
val token: String, val token: String,
val nickname: String, val nickname: String,
val email: String, val email: String,
val profileImage: String val profileImage: String,
@get:JsonProperty("isNew")
@param:JsonProperty("isNew")
val isNew: Boolean = false
) )
@@ -0,0 +1,219 @@
package kr.co.vividnext.sodalive.member
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.creator.admin.member.CreatorAdminMemberService
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.signUp.SignUpResponse
import kr.co.vividnext.sodalive.member.social.SocialAuthService
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class MemberLoginResponseContractTest @Autowired constructor(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var memberService: MemberService
@MockBean
private lateinit var creatorAdminMemberService: CreatorAdminMemberService
@MockBean
private lateinit var socialAuthServiceResolver: SocialAuthServiceResolver
@MockBean
private lateinit var trackingService: AdTrackingService
@MockBean
private lateinit var userActionService: UserActionService
private val loginResponse = LoginResponse(42L, "issued-token", "member", "", "profile-url")
@ParameterizedTest(name = "{0} isNew={1}")
@CsvSource(
"GOOGLE, false",
"GOOGLE, true",
"KAKAO, false",
"KAKAO, true",
"APPLE, false",
"APPLE, true",
"LINE, false",
"LINE, true"
)
@DisplayName("모든 소셜 로그인은 내부 가입 여부와 기존 JSON 계약 및 부수 동작을 유지한다")
fun shouldExposeSocialIsNewAsBooleanForEveryProvider(provider: MemberProvider, isNew: Boolean) {
val authService = Mockito.mock(SocialAuthService::class.java)
val nonce = if (provider == MemberProvider.APPLE || provider == MemberProvider.LINE) "login-nonce" else null
Mockito.`when`(socialAuthServiceResolver.resolve(provider)).thenReturn(authService)
Mockito.`when`(authService.authenticate("provider-token", "web", "campaign-pid", "push-token", nonce))
.thenReturn(SocialLoginResponse(42L, "campaign-pid", loginResponse, isNew))
val request = post("/member/login/${provider.name.lowercase()}")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""{
"container":"web", "marketingPid":"campaign-pid", "pushToken":"push-token",
"identityToken":"provider-token", "nonce":"login-nonce"
}"""
)
if (nonce == null) request.header("Authorization", "Bearer provider-token")
val response = perform(request)
Mockito.verify(authService).authenticate("provider-token", "web", "campaign-pid", "push-token", nonce)
Mockito.verify(trackingService).saveTrackingHistory("campaign-pid", AdTrackingHistoryType.SIGNUP, 42L)
Mockito.verify(userActionService, Mockito.times(if (isNew) 1 else 0))
.recordAction(42L, false, ActionType.SIGN_UP)
Mockito.verifyNoMoreInteractions(trackingService, userActionService)
assertEnvelope(response, "Congratulations on your sign up.")
assertLoginData(response.path("data"), isNew)
}
@ParameterizedTest
@ValueSource(strings = ["/member/signup", "/member/signup/v2"])
@DisplayName("두 이메일 가입 응답은 서비스 DTO 기본값과 무관하게 신규 회원 여부를 true로 반환한다")
fun shouldReturnNewForBothEmailSignupEndpoints(path: String) {
val signupResponse = SignUpResponse(42L, "campaign-pid", loginResponse)
val request = if (path == "/member/signup") {
val body = """{
"email":"member@test.com", "password":"test-password", "nickname":"member", "gender":"NONE",
"marketingPid":"campaign-pid", "isAgreeTermsOfService":true, "isAgreePrivacyPolicy":true, "container":"web"
}"""
Mockito.`when`(memberService.signUp(null, body)).thenReturn(signupResponse)
multipart(path).file(MockMultipartFile("request", "", MediaType.TEXT_PLAIN_VALUE, body.toByteArray()))
} else {
Mockito.`when`(
memberService.signUpV2(
SignUpRequestV2(
email = "member@test.com",
password = "test-password",
marketingPid = "campaign-pid",
isAgreeTermsOfService = true,
isAgreePrivacyPolicy = true,
container = "web"
)
)
).thenReturn(signupResponse)
post(path).contentType(MediaType.APPLICATION_JSON).content(
"""{
"email":"member@test.com", "password":"test-password", "marketingPid":"campaign-pid",
"isAgreeTermsOfService":true, "isAgreePrivacyPolicy":true, "container":"web"
}"""
)
}
val response = perform(request)
Mockito.verify(trackingService).saveTrackingHistory("campaign-pid", AdTrackingHistoryType.SIGNUP, 42L)
Mockito.verify(userActionService, Mockito.times(if (path == "/member/signup/v2") 1 else 0))
.recordAction(42L, false, ActionType.SIGN_UP)
Mockito.verifyNoMoreInteractions(trackingService, userActionService)
assertEnvelope(response, "Congratulations on your sign up.")
assertLoginData(response.path("data"), true)
}
@ParameterizedTest(name = "isAdmin={0}, isCreator={1}")
@CsvSource("false, false", "true, false", "false, true")
@DisplayName("일반 및 관리자 및 크리에이터 회원 로그인은 신규 회원 여부를 false로 반환한다")
fun shouldReturnFalseForEveryMemberLoginBranch(isAdmin: Boolean, isCreator: Boolean) {
val request = LoginRequest("member@test.com", "test-password", isAdmin, isCreator)
Mockito.`when`(memberService.login(request))
.thenReturn(ApiResponse.ok(data = loginResponse, message = "You are logged in."))
val response = perform(
post("/member/login").contentType(MediaType.APPLICATION_JSON).content(
"""{"email":"member@test.com","password":"test-password","isAdmin":$isAdmin,"isCreator":$isCreator}"""
)
)
Mockito.verify(memberService).login(request)
assertEnvelope(response, "You are logged in.")
assertLoginData(response.path("data"), false)
}
@Test
@DisplayName("크리에이터 관리자 로그인은 공유 응답의 신규 회원 여부를 false로 반환한다")
fun shouldReturnFalseForCreatorAdminLogin() {
val request = LoginRequest("member@test.com", "test-password")
Mockito.`when`(creatorAdminMemberService.login(request))
.thenReturn(ApiResponse.ok(data = loginResponse, message = "Logged in."))
val response = perform(
post("/creator-admin/member/login").contentType(MediaType.APPLICATION_JSON)
.content("""{"email":"member@test.com","password":"test-password"}""")
)
Mockito.verify(creatorAdminMemberService).login(request)
assertEnvelope(response, "Logged in.")
assertLoginData(response.path("data"), false)
}
@Test
@DisplayName("애플리케이션 Jackson은 기본 LoginResponse의 isNew false를 생략하지 않는다")
fun shouldSerializeDefaultLoginResponseWithIsNewFalse() {
assertLoginData(objectMapper.readTree(objectMapper.writeValueAsString(loginResponse)), false)
}
private fun perform(request: MockHttpServletRequestBuilder): JsonNode {
val response = mockMvc.perform(request.header("Accept-Language", "en"))
.andExpect(status().isOk)
.andReturn().response
return objectMapper.readTree(response.contentAsByteArray)
}
private fun assertEnvelope(response: JsonNode, message: String) {
assertEquals(setOf("success", "message", "data", "errorProperty"), response.fieldNames().asSequence().toSet())
assertTrue(response.path("success").isBoolean)
assertTrue(response.path("success").booleanValue())
assertEquals(message, response.path("message").textValue())
assertTrue(response.path("errorProperty").isNull)
}
private fun assertLoginData(data: JsonNode, isNew: Boolean) {
assertTrue(data.path("isNew").isBoolean, "isNew must be a present JSON boolean: $data")
assertEquals(isNew, data.path("isNew").booleanValue())
assertFalse(data.has("new"))
assertEquals(
setOf("userId", "token", "nickname", "email", "profileImage", "isNew"),
data.fieldNames().asSequence().toSet()
)
assertEquals(42L, data.path("userId").longValue())
assertEquals("issued-token", data.path("token").textValue())
assertEquals("member", data.path("nickname").textValue())
assertEquals("", data.path("email").textValue())
assertEquals("profile-url", data.path("profileImage").textValue())
}
}