diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 74df0080..9aca5480 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -77,7 +77,7 @@ class MemberController( ) 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") @@ -96,7 +96,7 @@ class MemberController( } 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") @@ -440,7 +440,7 @@ class MemberController( } 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt index 667f06d8..4e7d3d26 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginResponse.kt @@ -1,9 +1,14 @@ package kr.co.vividnext.sodalive.member.login +import com.fasterxml.jackson.annotation.JsonProperty + data class LoginResponse( val userId: Long, val token: String, val nickname: String, val email: String, - val profileImage: String + val profileImage: String, + @get:JsonProperty("isNew") + @param:JsonProperty("isNew") + val isNew: Boolean = false ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberLoginResponseContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberLoginResponseContractTest.kt new file mode 100644 index 00000000..4d1513e7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberLoginResponseContractTest.kt @@ -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()) + } +}