From 25afafab88db7943efd2b5fd8516b9e8c4f8cadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 15:49:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EB=93=A4=20=EB=95=8C=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=97=86=EC=9D=B4=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4=20(178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일정은 저장할 때의 공유 코드를 문서에 굳혀 넣는다. 신호가 약한 곳에서 처음 로그인하면 코드가 비어 있고, 그 사이에 만든 일정은 sharedCode 가 빈 채로 남는다. 보안 규칙의 공유 읽기가 sharedCode != "" 를 요구하므로 그 일정들은 가족이 어떤 코드로도 읽지 못한다. 사용자 쪽에서는 코드를 만들어 가족에게 알려 줬는데 일정이 안 보인다. 화면 어디에도 이유가 없고 되돌릴 길도 없다 — 「공유 코드 다시 만들기」(#134)는 사용자 문서만 고쳐서, 고쳤다는 잘못된 안심만 주고 있었다. 코드를 보장하는 자리(로그인 직후와 다시 시도 버튼) 뒤에서 코드가 빈 내 일정에 코드를 채운다. 실패해도 코드 만들기 결과를 뒤집지 않는다 — 다음 로그인에서 다시 맞춘다. --- .../com/example/slowclock/MainActivity.kt | 6 +- .../com/example/slowclock/auth/AuthManager.kt | 13 ++- .../remote/repository/ScheduleRepository.kt | 46 ++++++++++ .../ScheduleRepositoryAddScheduleTest.kt | 84 +++++++++++++++++++ .../slowclock/ui/profile/ProfileViewModel.kt | 5 ++ .../ui/profile/ProfileViewModelTest.kt | 33 +++++++- 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/example/slowclock/MainActivity.kt b/app/src/main/java/com/example/slowclock/MainActivity.kt index 1ee36bec..d3944e7b 100644 --- a/app/src/main/java/com/example/slowclock/MainActivity.kt +++ b/app/src/main/java/com/example/slowclock/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.lifecycleScope import com.example.slowclock.auth.AuthManager import com.example.slowclock.data.model.ThemeMode import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.domain.profile.SignOutUseCase @@ -38,6 +39,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userRepository: UserRepository + @Inject + lateinit var scheduleRepository: ScheduleRepository + @Inject lateinit var authRepository: AuthRepository @@ -73,7 +77,7 @@ class MainActivity : ComponentActivity() { try { // AuthManager 초기화 - authManager = AuthManager(this, userRepository, authRepository) + authManager = AuthManager(this, userRepository, authRepository, scheduleRepository) authManager.initialize( onSuccess = { Log.d("AUTH", "로그인 성공 콜백") diff --git a/app/src/main/java/com/example/slowclock/auth/AuthManager.kt b/app/src/main/java/com/example/slowclock/auth/AuthManager.kt index ffb7a7c7..34e6ea05 100644 --- a/app/src/main/java/com/example/slowclock/auth/AuthManager.kt +++ b/app/src/main/java/com/example/slowclock/auth/AuthManager.kt @@ -8,6 +8,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.lifecycleScope import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.data.remote.repository.UserRepository import com.firebase.ui.auth.AuthUI import com.firebase.ui.auth.IdpResponse @@ -21,6 +22,7 @@ class AuthManager( private val activity: ComponentActivity, private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val scheduleRepository: ScheduleRepository, ) { private companion object { // GitHub Pages(docs/) 에 게시된 문서. Play 콘솔의 개인정보처리방침 URL 과 같은 주소를 쓴다. @@ -53,14 +55,21 @@ class AuthManager( } } - /** 사용자 문서와 공유 코드를 보장한다. 이름·이메일이 바뀌었으면 함께 맞춘다. */ + /** + * 사용자 문서와 공유 코드를 보장한다. 이름·이메일이 바뀌었으면 함께 맞춘다. + * + * 코드를 얻은 뒤에는 코드 없이 저장돼 있던 일정에도 채운다. 코드만 만들면 그 전에 만든 + * 일정은 가족이 영영 못 읽는다(#178). + */ fun ensureShareCodeForUser( uid: String, name: String, email: String, ) { activity.lifecycleScope.launch { - if (!userRepository.ensureShareCode(uid, name, email)) { + if (userRepository.ensureShareCode(uid, name, email)) { + scheduleRepository.fillMissingSharedCode(uid) + } else { Log.e("AUTH", "공유 코드 생성/저장 실패") } } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt index 85900f18..ae084185 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt @@ -470,6 +470,52 @@ class ScheduleRepository } } + /** + * 공유 코드 없이 저장된 내 일정에 코드를 채운다. 채운 건수를 낸다. + * + * 일정은 저장할 때의 공유 코드를 문서에 굳혀 넣는다. 신호가 약한 곳에서 처음 로그인하면 + * 코드가 비어 있고, 그 사이에 만든 일정은 `sharedCode` 가 빈 채로 남는다. 보안 규칙의 + * 공유 읽기가 `sharedCode != ""` 를 요구하므로, 나중에 코드를 만들어도 그 일정들은 + * 가족이 어떤 코드로도 읽지 못한다. + * + * 사용자 쪽에서는 코드를 만들어 가족에게 알려 줬는데 일정이 안 보인다. 화면 어디에도 + * 이유가 없고 되돌릴 길도 없다 — 「공유 코드 다시 만들기」(#134)는 사용자 문서만 고쳤다. + * 그래서 코드를 보장하는 자리에서 이미 저장된 일정도 함께 맞춘다(#178). + * + * 실패해도 던지지 않는다. 코드를 만든 결과를 이 일이 뒤집으면 안 되고, 다음 로그인에서 + * 다시 맞추면 된다. + */ + suspend fun fillMissingSharedCode(userId: String): Int = + try { + val shareCode = fetchShareCode(userId) + if (shareCode.isBlank()) { + 0 + } else { + val stale = + schedulesCollection + .whereEqualTo("userId", userId) + .whereEqualTo("sharedCode", "") + .get() + .await() + .documents + stale.chunked(FirestoreCollections.BATCH_LIMIT).forEach { chunk -> + val batch = firestore.batch() + chunk.forEach { document -> + batch.update( + document.reference, + mapOf("sharedCode" to shareCode, "updatedAt" to Timestamp.now()), + ) + } + batch.commit().await() + } + if (stale.isNotEmpty()) Log.d("ScheduleRepo", "공유 코드를 채운 일정: ${stale.size}건") + stale.size + } + } catch (e: Exception) { + Log.e("ScheduleRepo", "빠진 공유 코드 채우기 실패", e) + 0 + } + // 계정 삭제용: 사용자가 만든 일정 전부 삭제 suspend fun deleteAllSchedulesOf(userId: String): Boolean = try { diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAddScheduleTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAddScheduleTest.kt index bf87ea3e..55a4db39 100644 --- a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAddScheduleTest.kt +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAddScheduleTest.kt @@ -10,8 +10,12 @@ import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestoreException +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.QuerySnapshot +import com.google.firebase.firestore.WriteBatch import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -82,6 +86,86 @@ class ScheduleRepositoryAddScheduleTest { ) } + /** + * 공유 코드 없이 저장된 일정을 찾아 채우는 경로를 세운다. + * + * [shareCode] 를 사용자 문서가 낸다고 보고, 빈 코드로 저장된 일정 [staleCount] 건이 + * 걸린다고 둔다. 반환하는 batch 로 실제 갱신 건수를 센다. + */ + private fun repositoryForFill( + shareCode: String, + staleCount: Int, + ): Pair { + val user = mockk() + every { user.uid } returns uid + val auth = mockk() + every { auth.currentUser } returns user + + val userSnapshot = mockk() + every { userSnapshot.getString("shareCode") } returns shareCode + val userDocRef = mockk() + every { userDocRef.get() } returns Tasks.forResult(userSnapshot) + val usersCollection = mockk() + every { usersCollection.document(uid) } returns userDocRef + + val stale = + (1..staleCount).map { + mockk().also { document -> + every { document.reference } returns mockk() + } + } + val querySnapshot = mockk() + every { querySnapshot.documents } returns stale + val byShareCode = mockk() + every { byShareCode.get() } returns Tasks.forResult(querySnapshot) + val byUser = mockk() + every { byUser.whereEqualTo("sharedCode", "") } returns byShareCode + val schedulesCollection = mockk() + every { schedulesCollection.whereEqualTo("userId", uid) } returns byUser + + val batch = mockk() + every { batch.update(any(), any>()) } returns batch + every { batch.commit() } returns Tasks.forResult(null) + + val firestore = mockk() + every { firestore.collection("schedules") } returns schedulesCollection + every { firestore.collection("users") } returns usersCollection + every { firestore.batch() } returns batch + + return ScheduleRepository(auth, firestore) to batch + } + + @Test + fun `코드 없이 저장된 일정에 공유 코드를 채운다`() = + runTest { + // 코드가 빈 채로 저장된 일정은 보안 규칙의 공유 읽기가 sharedCode != "" 를 요구해 + // 가족이 어떤 코드로도 읽지 못한다. 코드를 만든 자리에서 함께 맞춘다(#178). + val (repository, batch) = repositoryForFill(shareCode = "ABC123", staleCount = 3) + + assertEquals(3, repository.fillMissingSharedCode(uid)) + verify(exactly = 3) { batch.update(any(), any>()) } + verify(exactly = 1) { batch.commit() } + } + + @Test + fun `채울 일정이 없으면 아무것도 쓰지 않는다`() = + runTest { + val (repository, batch) = repositoryForFill(shareCode = "ABC123", staleCount = 0) + + assertEquals(0, repository.fillMissingSharedCode(uid)) + verify(exactly = 0) { batch.commit() } + } + + @Test + fun `내 공유 코드가 아직 없으면 일정을 건드리지 않는다`() = + runTest { + // 빈 코드로 덮어쓰면 아무것도 고쳐지지 않고 updatedAt 만 흔들린다. + val (repository, batch) = repositoryForFill(shareCode = "", staleCount = 3) + + assertEquals(0, repository.fillMissingSharedCode(uid)) + verify(exactly = 0) { batch.update(any(), any>()) } + } + @Test fun `제목이 비어 있으면 Firestore 를 부르기 전에 막는다`() = runTest { diff --git a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt index b91446c3..78bbce13 100644 --- a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileViewModel.kt @@ -2,6 +2,7 @@ package com.example.slowclock.ui.profile import androidx.lifecycle.viewModelScope import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.domain.profile.DeleteAccountResult import com.example.slowclock.domain.profile.DeleteAccountStep @@ -18,6 +19,7 @@ class ProfileViewModel constructor( private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val scheduleRepository: ScheduleRepository, private val deleteAccount: DeleteAccountUseCase, private val signOutUseCase: SignOutUseCase, ) : MviViewModel(ProfileUiState()) { @@ -116,6 +118,9 @@ class ProfileViewModel email = profile.email, ) if (created) { + // 코드만 만들면 그 전에 저장한 일정은 sharedCode 가 빈 채라 가족이 영영 + // 못 읽는다. 다시 시도 버튼이 「고쳤다」 는 잘못된 안심을 주면 안 된다(#178). + scheduleRepository.fillMissingSharedCode(profile.uid) dispatch(ProfileReducerEvent.ShareCodeRetryFinished()) loadProfile() } else { diff --git a/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt b/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt index 4f4a72a2..83ef8332 100644 --- a/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt +++ b/feature/profile/src/test/java/com/example/slowclock/ui/profile/ProfileViewModelTest.kt @@ -2,6 +2,7 @@ package com.example.slowclock.ui.profile import com.example.slowclock.data.model.User import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.domain.profile.DeleteAccountResult import com.example.slowclock.domain.profile.DeleteAccountStep @@ -37,6 +38,7 @@ class ProfileViewModelTest { private val authRepository = mockk() private val deleteAccount = mockk() private val signOutUseCase = mockk(relaxed = true) + private val scheduleRepository = mockk() @Before fun setUp() { @@ -46,6 +48,7 @@ class ProfileViewModelTest { coEvery { userRepository.getCurrentUser() } returns User(id = "uid-1", name = "정일혁", email = "user@example.com", shareCode = "ABC123") justRun { authRepository.signOut() } + coEvery { scheduleRepository.fillMissingSharedCode(any()) } returns 0 } @After @@ -53,7 +56,7 @@ class ProfileViewModelTest { Dispatchers.resetMain() } - private fun createViewModel() = ProfileViewModel(userRepository, authRepository, deleteAccount, signOutUseCase) + private fun createViewModel() = ProfileViewModel(userRepository, authRepository, scheduleRepository, deleteAccount, signOutUseCase) @Test fun `Firestore 사용자 문서로 이름·이메일·공유 코드를 채운다`() = @@ -181,6 +184,34 @@ class ProfileViewModelTest { assertFalse(viewModel.uiState.value.isRetryingShareCode) } + @Test + fun `공유 코드를 만들면 코드 없이 저장된 일정도 함께 맞춘다`() = + runTest { + // 코드만 만들면 그 전에 저장한 일정은 sharedCode 가 빈 채라 가족이 영영 못 읽는다. + // 다시 시도 버튼이 「고쳤다」 는 잘못된 안심을 주면 안 된다(#178). + coEvery { userRepository.getCurrentUser() } returns + User(id = "uid-1", name = "정일혁", email = "user@example.com", shareCode = "") + coEvery { userRepository.ensureShareCode("uid-1", any(), any()) } returns true + val viewModel = createViewModel() + + viewModel.onIntent(ProfileIntent.RetryShareCode) + + coVerify(exactly = 1) { scheduleRepository.fillMissingSharedCode("uid-1") } + } + + @Test + fun `공유 코드를 못 만들면 일정은 건드리지 않는다`() = + runTest { + coEvery { userRepository.getCurrentUser() } returns + User(id = "uid-1", name = "정일혁", email = "user@example.com", shareCode = "") + coEvery { userRepository.ensureShareCode(any(), any(), any()) } returns false + val viewModel = createViewModel() + + viewModel.onIntent(ProfileIntent.RetryShareCode) + + coVerify(exactly = 0) { scheduleRepository.fillMissingSharedCode(any()) } + } + @Test fun `공유 코드를 또 못 만들면 이유를 알린다`() = runTest {