diff --git a/core/domain/src/test/java/com/afternote/core/domain/usecase/auth/LoginUseCaseTest.kt b/core/domain/src/test/java/com/afternote/core/domain/usecase/auth/LoginUseCaseTest.kt new file mode 100644 index 00000000..ef08bfef --- /dev/null +++ b/core/domain/src/test/java/com/afternote/core/domain/usecase/auth/LoginUseCaseTest.kt @@ -0,0 +1,176 @@ +package com.afternote.core.domain.usecase.auth + +import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.model.Session +import com.afternote.core.model.TokenBundle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [LoginUseCase] 비즈니스 로직 회귀 가드. + * + * 검증 핵심: + * 1. 로그인 타입(Email/Kakao/Google)에 맞는 [AuthRepository] 메서드로 분기하는지 + * 2. 로그인 성공 시 받은 세션 토큰을 그대로 [AuthRepository.saveSession]에 전달하는지 + * 3. 로그인 실패면 **saveSession을 호출하지 않고** 그 실패를 그대로 반환(short-circuit)하는지 + * 4. saveSession 실패면 그 실패를 반환하는지 + * 5. 반환값(신규 가입자 여부) — 소셜 `newUser=true` 만 true, 이메일·기존(false)·null 은 false + * + * 외부 라이브러리(mockk 등) 없이 호출 인자/횟수를 기록하는 직접 작성 fake를 사용한다. + */ +class LoginUseCaseTest { + @Test + fun `Email 로그인 성공 - defaultLogin 호출 후 세션 토큰으로 saveSession`() { + val repo = + FakeAuthRepository().apply { + defaultLoginResult = Result.success(Session.DefaultSession(accessToken = "AT", refreshToken = "RT")) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Email(email = "a@b.com", password = "pw")) } + + assertTrue(result.isSuccess) + assertFalse(result.getOrThrow()) // 이메일 로그인 = 기존 유저(false) + assertEquals("a@b.com" to "pw", repo.defaultLoginArgs) + assertEquals("AT" to "RT", repo.saveSessionArgs) + assertEquals(1, repo.saveSessionCallCount) + } + + @Test + fun `Kakao 로그인 성공 - kakaoLogin 호출 + saveSession`() { + val repo = + FakeAuthRepository().apply { + kakaoResult = + Result.success(Session.SocialSession(accessToken = "KAT", refreshToken = "KRT", isNewUser = true)) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Kakao(oauthToken = "kakao-token")) } + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow()) // isNewUser=true → 신규 + assertEquals("kakao-token", repo.kakaoArg) + assertEquals("KAT" to "KRT", repo.saveSessionArgs) + } + + @Test + fun `Google 로그인 성공 - googleLogin 호출 + saveSession`() { + val repo = + FakeAuthRepository().apply { + googleResult = + Result.success(Session.SocialSession(accessToken = "GAT", refreshToken = "GRT", isNewUser = false)) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Google(idToken = "google-id-token")) } + + assertTrue(result.isSuccess) + assertFalse(result.getOrThrow()) // isNewUser=false → 기존 + assertEquals("google-id-token", repo.googleArg) + assertEquals("GAT" to "GRT", repo.saveSessionArgs) + } + + @Test + fun `소셜 로그인 - isNewUser null 이면 false (기존 유저 취급)`() { + val repo = + FakeAuthRepository().apply { + kakaoResult = + Result.success(Session.SocialSession(accessToken = "KAT", refreshToken = "KRT", isNewUser = null)) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Kakao(oauthToken = "kakao-token")) } + + assertTrue(result.isSuccess) + assertFalse(result.getOrThrow()) // null → false (`== true` 가 null 흡수) + } + + @Test + fun `로그인 실패면 saveSession 호출하지 않고 실패를 그대로 반환`() { + val loginError = IllegalStateException("login failed") + val repo = + FakeAuthRepository().apply { + defaultLoginResult = Result.failure(loginError) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Email(email = "a@b.com", password = "pw")) } + + assertTrue(result.isFailure) + assertSame(loginError, result.exceptionOrNull()) + assertEquals(0, repo.saveSessionCallCount) + assertNull(repo.saveSessionArgs) + } + + @Test + fun `saveSession 실패면 그 실패를 반환`() { + val saveError = IllegalStateException("save failed") + val repo = + FakeAuthRepository().apply { + defaultLoginResult = Result.success(Session.DefaultSession(accessToken = "AT", refreshToken = "RT")) + saveSessionResult = Result.failure(saveError) + } + val result = runBlocking { LoginUseCase(repo)(LoginType.Email(email = "a@b.com", password = "pw")) } + + assertFalse(result.isSuccess) + assertSame(saveError, result.exceptionOrNull()) + assertEquals(1, repo.saveSessionCallCount) + } + + private class FakeAuthRepository : AuthRepository { + var defaultLoginArgs: Pair? = null + var kakaoArg: String? = null + var googleArg: String? = null + var saveSessionArgs: Pair? = null + var saveSessionCallCount = 0 + + var defaultLoginResult: Result = + Result.success(Session.DefaultSession(accessToken = "at", refreshToken = "rt")) + var kakaoResult: Result = + Result.success(Session.SocialSession(accessToken = "at", refreshToken = "rt", isNewUser = false)) + var googleResult: Result = + Result.success(Session.SocialSession(accessToken = "at", refreshToken = "rt", isNewUser = false)) + var saveSessionResult: Result = Result.success(Unit) + + override val isLoggedIn: Flow = flowOf(false) + + override suspend fun saveSession( + accessToken: String, + refreshToken: String, + ): Result { + saveSessionCallCount++ + saveSessionArgs = accessToken to refreshToken + return saveSessionResult + } + + override suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ): Result = Result.success(Unit) + + override suspend fun clearSession(): Result = Result.success(Unit) + + override suspend fun getAccessToken(): Result = Result.success(null) + + override suspend fun getRefreshToken(): Result = Result.success(null) + + override suspend fun defaultLogin( + email: String, + password: String, + ): Result { + defaultLoginArgs = email to password + return defaultLoginResult + } + + override suspend fun kakaoLogin(oauthToken: String): Result { + kakaoArg = oauthToken + return kakaoResult + } + + override suspend fun googleLogin(idToken: String): Result { + googleArg = idToken + return googleResult + } + + override suspend fun rotateToken(): Result = Result.success(TokenBundle(accessToken = "at", refreshToken = "rt")) + + override suspend fun logout(): Result = Result.success(Unit) + } +} diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt new file mode 100644 index 00000000..306a07cf --- /dev/null +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt @@ -0,0 +1,149 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.repository.author.MediaInput +import com.afternote.feature.afternote.domain.repository.author.MemorialPhotoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialVideoUploadRepository +import com.afternote.feature.afternote.domain.repository.author.PhotoUploadOutcome +import com.afternote.feature.afternote.domain.repository.author.VideoUploadOutcome +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [ResolveMemorialMediaForSaveUseCase] 비즈니스 로직 회귀 가드. + * + * 검증 핵심: + * 1. 입력 [MediaInput] 을 각 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) + * 2. sealed [VideoUploadOutcome]/[PhotoUploadOutcome] 분기를 저장 페이로드 URL 로 매핑하는지 + * (Empty→null, Existing→url, FreshlyUploaded→url) + * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진 Repository 는 호출하지 않은 채** + * short-circuit 하는지 (non-local return) + * 4. 사진 resolve 실패면 [MemorialPhotoSaveException] 으로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지 + * + * 외부 라이브러리(mockk 등) 없이 호출 인자/횟수를 기록하는 직접 작성 fake를 사용한다. + */ +class ResolveMemorialMediaForSaveUseCaseTest { + @Test + fun `영상-사진 모두 성공 - FreshlyUploaded 영상과 Existing 사진을 각 URL 로 매핑`() { + val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.FreshlyUploaded("v-url")) } + val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Existing("p-url")) } + + val result = + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + video = MediaInput.Local("content://video"), + photo = MediaInput.Remote("https://cdn/photo.jpg"), + ) + } + + assertTrue(result.isSuccess) + assertEquals("v-url", result.getOrThrow().resolvedVideoUrl) + assertEquals("p-url", result.getOrThrow().resolvedMemorialPhotoUrl) + } + + @Test + fun `Empty Outcome 은 null 로 매핑 - 미첨부`() { + val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } + val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.Empty) } + + val resolved = + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.None) + }.getOrThrow() + + assertNull(resolved.resolvedVideoUrl) + assertNull(resolved.resolvedMemorialPhotoUrl) + } + + @Test + fun `Existing 영상과 FreshlyUploaded 사진도 각 URL 로 매핑`() { + val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Existing("existing-v")) } + val photoRepo = FakePhotoUploadRepository().apply { result = Result.success(PhotoUploadOutcome.FreshlyUploaded("fresh-p")) } + + val resolved = + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)( + video = MediaInput.Remote("https://cdn/existing-v.mp4"), + photo = MediaInput.Local("content://fresh-photo"), + ) + }.getOrThrow() + + assertEquals("existing-v", resolved.resolvedVideoUrl) + assertEquals("fresh-p", resolved.resolvedMemorialPhotoUrl) + } + + @Test + fun `입력 MediaInput 을 각 Repository 에 그대로 전달`() { + val videoRepo = FakeVideoUploadRepository() + val photoRepo = FakePhotoUploadRepository() + + val videoInput = MediaInput.Local("content://fv") + val photoInput = MediaInput.Remote("https://cdn/mp.jpg") + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(videoInput, photoInput) + } + + assertEquals(videoInput, videoRepo.resolveVideoArg) + assertEquals(photoInput, photoRepo.resolvePhotoArg) + } + + @Test + fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진 Repository 는 호출하지 않음`() { + val videoError = IllegalStateException("video resolve failed") + val videoRepo = FakeVideoUploadRepository().apply { result = Result.failure(videoError) } + val photoRepo = FakePhotoUploadRepository() + + val result = + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.Local("content://v"), MediaInput.None) + } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is MemorialVideoSaveException) + assertSame(videoError, result.exceptionOrNull()?.cause) // 원본 예외를 cause 로 보존 + assertEquals(0, photoRepo.callCount) // short-circuit — 사진 Repository 미호출 + } + + @Test + fun `사진 resolve 실패면 MemorialPhotoSaveException 으로 wrap`() { + val photoError = IllegalStateException("photo resolve failed") + val videoRepo = FakeVideoUploadRepository().apply { result = Result.success(VideoUploadOutcome.Empty) } + val photoRepo = FakePhotoUploadRepository().apply { result = Result.failure(photoError) } + + val result = + runBlocking { + ResolveMemorialMediaForSaveUseCase(videoRepo, photoRepo)(MediaInput.None, MediaInput.Remote("https://cdn/p.jpg")) + } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is MemorialPhotoSaveException) + assertSame(photoError, result.exceptionOrNull()?.cause) + } + + private class FakeVideoUploadRepository : MemorialVideoUploadRepository { + var resolveVideoArg: MediaInput? = null + var callCount = 0 + var result: Result = Result.success(VideoUploadOutcome.Empty) + + override suspend fun resolveVideo(input: MediaInput): Result { + callCount++ + resolveVideoArg = input + return result + } + } + + private class FakePhotoUploadRepository : MemorialPhotoUploadRepository { + var resolvePhotoArg: MediaInput? = null + var callCount = 0 + var result: Result = Result.success(PhotoUploadOutcome.Empty) + + override suspend fun resolvePhoto(input: MediaInput): Result { + callCount++ + resolvePhotoArg = input + return result + } + } +}