Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/main/java/com/example/slowclock/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var userRepository: UserRepository

@Inject
lateinit var scheduleRepository: ScheduleRepository

@Inject
lateinit var authRepository: AuthRepository

Expand Down Expand Up @@ -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", "로그인 성공 콜백")
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/example/slowclock/auth/AuthManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 과 같은 주소를 쓴다.
Expand Down Expand Up @@ -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", "공유 코드 생성/저장 실패")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +86,86 @@ class ScheduleRepositoryAddScheduleTest {
)
}

/**
* 공유 코드 없이 저장된 일정을 찾아 채우는 경로를 세운다.
*
* [shareCode] 를 사용자 문서가 낸다고 보고, 빈 코드로 저장된 일정 [staleCount] 건이
* 걸린다고 둔다. 반환하는 batch 로 실제 갱신 건수를 센다.
*/
private fun repositoryForFill(
shareCode: String,
staleCount: Int,
): Pair<ScheduleRepository, WriteBatch> {
val user = mockk<FirebaseUser>()
every { user.uid } returns uid
val auth = mockk<FirebaseAuth>()
every { auth.currentUser } returns user

val userSnapshot = mockk<DocumentSnapshot>()
every { userSnapshot.getString("shareCode") } returns shareCode
val userDocRef = mockk<DocumentReference>()
every { userDocRef.get() } returns Tasks.forResult(userSnapshot)
val usersCollection = mockk<CollectionReference>()
every { usersCollection.document(uid) } returns userDocRef

val stale =
(1..staleCount).map {
mockk<DocumentSnapshot>().also { document ->
every { document.reference } returns mockk<DocumentReference>()
}
}
val querySnapshot = mockk<QuerySnapshot>()
every { querySnapshot.documents } returns stale
val byShareCode = mockk<Query>()
every { byShareCode.get() } returns Tasks.forResult(querySnapshot)
val byUser = mockk<Query>()
every { byUser.whereEqualTo("sharedCode", "") } returns byShareCode
val schedulesCollection = mockk<CollectionReference>()
every { schedulesCollection.whereEqualTo("userId", uid) } returns byUser

val batch = mockk<WriteBatch>()
every { batch.update(any<DocumentReference>(), any<Map<String, Any>>()) } returns batch
every { batch.commit() } returns Tasks.forResult(null)

val firestore = mockk<FirebaseFirestore>()
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<DocumentReference>(), any<Map<String, 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<DocumentReference>(), any<Map<String, Any>>()) }
}

@Test
fun `제목이 비어 있으면 Firestore 를 부르기 전에 막는다`() =
runTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ProfileIntent, ProfileUiState, ProfileReducerEvent>(ProfileUiState()) {
Expand Down Expand Up @@ -116,6 +118,9 @@ class ProfileViewModel
email = profile.email,
)
if (created) {
// 코드만 만들면 그 전에 저장한 일정은 sharedCode 가 빈 채라 가족이 영영
// 못 읽는다. 다시 시도 버튼이 「고쳤다」 는 잘못된 안심을 주면 안 된다(#178).
scheduleRepository.fillMissingSharedCode(profile.uid)
dispatch(ProfileReducerEvent.ShareCodeRetryFinished())
loadProfile()
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +38,7 @@ class ProfileViewModelTest {
private val authRepository = mockk<AuthRepository>()
private val deleteAccount = mockk<DeleteAccountUseCase>()
private val signOutUseCase = mockk<SignOutUseCase>(relaxed = true)
private val scheduleRepository = mockk<ScheduleRepository>()

@Before
fun setUp() {
Expand All @@ -46,14 +48,15 @@ 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
fun tearDown() {
Dispatchers.resetMain()
}

private fun createViewModel() = ProfileViewModel(userRepository, authRepository, deleteAccount, signOutUseCase)
private fun createViewModel() = ProfileViewModel(userRepository, authRepository, scheduleRepository, deleteAccount, signOutUseCase)

@Test
fun `Firestore 사용자 문서로 이름·이메일·공유 코드를 채운다`() =
Expand Down Expand Up @@ -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 {
Expand Down
Loading