diff --git a/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt index 044d152..8d6ae06 100644 --- a/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt +++ b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt @@ -5,6 +5,10 @@ sealed class AppError( val message: String, ) { // 네트워크 관련 에러 + object ScheduleConflictError : AppError("이 일정은 이미 다른 내용으로 저장되어 있습니다. 목록에서 확인한 뒤 수정해 주세요.") + + object OnlineWriteError : AppError("일정 변경에는 인터넷 연결이 필요합니다. 연결을 확인하고 다시 시도해 주세요.") + object NetworkError : AppError("인터넷 연결을 확인해주세요") object TimeoutError : AppError("요청 시간이 초과되었습니다. 다시 시도해주세요") 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 1ac6ddb..0d02673 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 @@ -13,8 +13,12 @@ import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestoreException import com.google.firebase.firestore.Source +import com.google.firebase.firestore.Transaction +import com.google.firebase.firestore.toObject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOf @@ -159,6 +163,8 @@ class ScheduleRepository .sortedBy { it.startTime } ScheduleResult.Success(selectedDateSchedules) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { ScheduleResult.Error(e.toAppError()) } @@ -186,199 +192,160 @@ class ScheduleRepository return userShareCode } - // 일정 추가 - suspend fun addSchedule(schedule: Schedule): ScheduleResult { - val uid = auth.currentUser?.uid - if (uid == null) { - Log.e("ScheduleRepo", "사용자 로그인 안됨") - return ScheduleResult.Error(AppError.AuthError) - } - - // 데이터 검증 - if (schedule.title.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - return try { - // 공유 코드 읽기도 저장과 같은 try 안에 둔다. 이 읽기는 오프라인이거나 토큰이 - // 무효해지면 그대로 던지는데, 호출자인 ViewModel 은 viewModelScope 에서 부르고 - // 잡지 않는다. try 밖에 두면 저장 실패는 안내가 되고 이 읽기 실패만 앱을 - // 강제 종료시킨다(#133). - val userShareCode = fetchShareCode(uid) - val newSchedule = - schedule.copy( - userId = uid, - sharedCode = userShareCode, - createdAt = Timestamp.now(), - updatedAt = Timestamp.now(), - ) - - val docRef = schedulesCollection.document() - val scheduleWithId = newSchedule.copy(id = docRef.id) - Log.d("ScheduleRepo", "일정 저장 시도: ${scheduleWithId.title}") - - docRef.set(scheduleWithId).await() - Log.d("ScheduleRepo", "일정 저장 성공: ${docRef.id}") - ScheduleResult.Success(docRef.id) - } catch (e: ShareCodeOwnershipException) { - ScheduleResult.Error(AppError.GeneralError(e.message.orEmpty())) - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "Firestore 저장 에러: ${e.code}", e) - val error = - when (e.code) { - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - FirebaseFirestoreException.Code.DEADLINE_EXCEEDED -> AppError.TimeoutError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.RESOURCE_EXHAUSTED -> AppError.StorageFullError - else -> AppError.SaveError - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 저장 실패", e) - ScheduleResult.Error(AppError.SaveError) + /** 같은 제출 ID로만 만들고, 이미 확인된 요청은 서버 문서를 보존해 반환한다. */ + suspend fun addSchedule(schedule: Schedule): ScheduleResult { + val uid = auth.currentUser?.uid ?: return ScheduleResult.Error(AppError.AuthError) + if (schedule.id.isBlank() || schedule.title.isBlank()) return ScheduleResult.Error(AppError.InvalidDataError) + val ref = schedulesCollection.document(schedule.id) + return onlineWrite { + val existing = get(ref) + if (existing.exists()) { + val saved = requireNotNull(existing.toObject()).copy(id = schedule.id) + requireOwner(saved.userId, uid) + if (!saved.hasSameCreateInput(schedule)) throw ExistingScheduleConflictException() + saved + } else { + val code = get(usersCollection.document(uid)).getString("shareCode").orEmpty() + val saved = schedule.copy(userId = uid, sharedCode = code, createdAt = Timestamp.now(), updatedAt = Timestamp.now()) + registerShareCode(uid, code) + set(ref, saved) + saved + } } } - /** - * 일정 완료 상태 변경. - * - * 반복 일정은 회차마다 따로 남긴다([occurrenceDate] 가 그 회차의 날짜다). `completed` - * 는 문서에 하나뿐이라 반복 일정에 쓰면 한 번 완료한 뒤 영영 완료로 남는다(#130). - */ + /** 반복 일정은 completedDates의 해당 회차만 바꾼다. 감시자 권한은 규칙이 확인한다. */ suspend fun markScheduleAsCompleted( scheduleId: String, completed: Boolean = true, occurrenceDate: String = "", ): ScheduleResult { - if (scheduleId.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) - } - - return try { - Log.d("ScheduleRepo", "완료 상태 변경 시도: $scheduleId -> $completed ($occurrenceDate)") - + val uid = auth.currentUser?.uid ?: return ScheduleResult.Error(AppError.AuthError) + if (scheduleId.isBlank()) return ScheduleResult.Error(AppError.InvalidDataError) + val ref = schedulesCollection.document(scheduleId) + return onlineWrite { + val current = get(ref) + if (!current.exists()) throw missingSchedule() + // 보호자는 등록부를 변경하지 않는다. 본인 일정의 이전 코드만 같은 transaction에서 복구한다. + if (current.getString("userId") == uid) registerShareCode(uid, current.getString("sharedCode").orEmpty()) val change = if (occurrenceDate.isBlank()) { mapOf("completed" to completed) } else { - // 회차 하나만 더하거나 뺀다. 다른 기기가 같은 문서의 다른 회차를 건드려도 - // 서로 덮어쓰지 않는다. - val op = - if (completed) { - FieldValue.arrayUnion(occurrenceDate) - } else { - FieldValue.arrayRemove(occurrenceDate) - } - mapOf("completedDates" to op) - } - - schedulesCollection - .document(scheduleId) - .update(change + ("updatedAt" to Timestamp.now())) - .await() - - Log.d("ScheduleRepo", "완료 상태 변경 성공: $scheduleId -> $completed") - ScheduleResult.Success(Unit) - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "완료 상태 변경 실패: ${e.code}", e) - val error = - when (e.code) { - FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - else -> AppError.GeneralError("상태 변경에 실패했습니다") + mapOf( + "completedDates" to + if (completed) FieldValue.arrayUnion(occurrenceDate) else FieldValue.arrayRemove(occurrenceDate), + ) } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "완료 상태 변경 중 예상치 못한 에러", e) - ScheduleResult.Error(e.toAppError()) + update(ref, change + ("updatedAt" to Timestamp.now())) + Unit } } suspend fun updateSchedule(schedule: Schedule): ScheduleResult { - val uid = auth.currentUser?.uid - if (uid == null) { - return ScheduleResult.Error(AppError.AuthError) - } - - if (schedule.id.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) + val uid = auth.currentUser?.uid ?: return ScheduleResult.Error(AppError.AuthError) + if (schedule.id.isBlank() || schedule.title.isBlank()) return ScheduleResult.Error(AppError.InvalidDataError) + val ref = schedulesCollection.document(schedule.id) + return onlineWrite { + val current = get(ref) + if (!current.exists()) throw missingSchedule() + requireOwner(current.getString("userId"), uid) + registerShareCode(uid, current.getString("sharedCode").orEmpty()) + // 편집 필드만 쓰므로 최신 완료 기록·공유 코드·소유자·생성 시각은 그대로다. + update( + ref, + mapOf( + "title" to schedule.title, + "description" to schedule.description, + "startTime" to schedule.startTime, + "endTime" to schedule.endTime, + "recurring" to schedule.recurring, + "recurringType" to schedule.recurringType, + "updatedAt" to Timestamp.now(), + ), + ) + Unit } + } - if (schedule.title.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) + suspend fun deleteSchedule(scheduleId: String): ScheduleResult { + val uid = auth.currentUser?.uid ?: return ScheduleResult.Error(AppError.AuthError) + if (scheduleId.isBlank()) return ScheduleResult.Error(AppError.InvalidDataError) + val ref = schedulesCollection.document(scheduleId) + return onlineWrite { + val current = get(ref) + // 서버가 부재를 확인했으면 앞선 삭제의 재시도다. 알람 취소를 계속 진행할 수 있다. + if (current.exists()) { + requireOwner(current.getString("userId"), uid) + delete(ref) + } + Unit } + } - // 편집 화면이 바꾼 필드만 쓴다. 읽은 뒤 바뀐 완료 기록·공유 코드·소유자를 되돌리거나, - // 이미 삭제된 문서를 set 으로 다시 만들지 않는다. - val changes = - mapOf( - "title" to schedule.title, - "description" to schedule.description, - "startTime" to schedule.startTime, - "endTime" to schedule.endTime, - "recurring" to schedule.recurring, - "recurringType" to schedule.recurringType, - "updatedAt" to Timestamp.now(), - ) - - return try { - firestore.ensureShareCodeOwner(uid, schedule.sharedCode) - schedulesCollection - .document(schedule.id) - .update(changes) - .await() - - Log.d("ScheduleRepo", "일정 수정 성공: ${schedule.id}") - ScheduleResult.Success(Unit) - } catch (e: ShareCodeOwnershipException) { - ScheduleResult.Error(AppError.GeneralError(e.message.orEmpty())) + private suspend fun onlineWrite(block: Transaction.() -> T): ScheduleResult = + try { + val context = currentCoroutineContext() + context.ensureActive() + val result = + firestore + .runTransaction { transaction -> + // 재실행된 callback이 취소된 호출의 새 쓰기를 시작하지 않게 한다. + // 이미 서버에 전송된 commit을 await 취소로 철회할 수는 없다. + context.ensureActive() + transaction.block().also { context.ensureActive() } + }.await() + ScheduleResult.Success(result) + } catch (e: CancellationException) { + throw e + } catch (e: ExistingScheduleConflictException) { + ScheduleResult.Error(AppError.ScheduleConflictError) } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "일정 수정 실패: ${e.code}", e) val error = when (e.code) { + FirebaseFirestoreException.Code.UNAVAILABLE, + FirebaseFirestoreException.Code.DEADLINE_EXCEEDED, + -> AppError.OnlineWriteError + FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError + FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError + + FirebaseFirestoreException.Code.RESOURCE_EXHAUSTED -> AppError.StorageFullError + else -> AppError.SaveError } ScheduleResult.Error(error) } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 수정 중 예상치 못한 에러", e) + Log.e("ScheduleRepo", "온라인 일정 변경 실패", e) ScheduleResult.Error(AppError.SaveError) } - } - // 일정 삭제 - suspend fun deleteSchedule(scheduleId: String): ScheduleResult { - if (scheduleId.isBlank()) { - return ScheduleResult.Error(AppError.InvalidDataError) + private fun Transaction.registerShareCode( + uid: String, + code: String, + ) { + if (code.isNotBlank()) { + set(firestore.collection(FirestoreCollections.SHARE_CODES).document(code), mapOf("userId" to uid)) } + } - return try { - schedulesCollection - .document(scheduleId) - .delete() - .await() - - Log.d("ScheduleRepo", "일정 삭제 성공: $scheduleId") - ScheduleResult.Success(Unit) - } catch (e: FirebaseFirestoreException) { - Log.e("ScheduleRepo", "일정 삭제 실패: ${e.code}", e) - val error = - when (e.code) { - FirebaseFirestoreException.Code.NOT_FOUND -> AppError.NotFoundError - FirebaseFirestoreException.Code.PERMISSION_DENIED -> AppError.PermissionError - FirebaseFirestoreException.Code.UNAVAILABLE -> AppError.NetworkError - else -> AppError.GeneralError("삭제에 실패했습니다") - } - ScheduleResult.Error(error) - } catch (e: Exception) { - Log.e("ScheduleRepo", "일정 삭제 중 예상치 못한 에러", e) - ScheduleResult.Error(e.toAppError()) - } + private fun requireOwner( + owner: String?, + uid: String, + ) { + if (owner != uid) throw FirebaseFirestoreException("Schedule owner differs", FirebaseFirestoreException.Code.PERMISSION_DENIED) } + private fun missingSchedule() = FirebaseFirestoreException("Schedule does not exist", FirebaseFirestoreException.Code.NOT_FOUND) + + private fun Schedule.hasSameCreateInput(other: Schedule): Boolean = + title == other.title && description == other.description && startTime == other.startTime && + endTime == other.endTime && recurring == other.recurring && recurringType == other.recurringType && + familyGroupId == other.familyGroupId + + private class ExistingScheduleConflictException : Exception() + // ID로 일정 가져오기 (편집용) suspend fun getScheduleById(scheduleId: String): ScheduleResult { if (scheduleId.isBlank()) { @@ -407,6 +374,8 @@ class ScheduleRepository else -> AppError.NotFoundError } ScheduleResult.Error(error) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("ScheduleRepo", "일정 조회 중 예상치 못한 에러", e) ScheduleResult.Error(e.toAppError()) @@ -548,6 +517,8 @@ class ScheduleRepository if (stale.isNotEmpty()) Log.d("ScheduleRepo", "공유 코드를 채운 일정: ${stale.size}건") stale.size } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("ScheduleRepo", "빠진 공유 코드 채우기 실패", e) 0 @@ -567,6 +538,8 @@ class ScheduleRepository batch.commit().await() } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("ScheduleRepo", "사용자 일정 일괄 삭제 실패", e) false 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 0c010c2..04dbd19 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 @@ -32,21 +32,12 @@ class ScheduleRepositoryAddScheduleTest { private val uid = "uid-1" private fun repositoryWith(userDocTask: com.google.android.gms.tasks.Task): ScheduleRepository { - val user = mockk() - every { user.uid } returns uid - val auth = mockk() - every { auth.currentUser } returns user - - val userDocRef = mockk() - every { userDocRef.get() } returns userDocTask - val usersCollection = mockk() - every { usersCollection.document(uid) } returns userDocRef - val schedulesCollection = mockk() - val firestore = mockk() - every { firestore.collection("schedules") } returns schedulesCollection - every { firestore.collection("users") } returns usersCollection - - return ScheduleRepository(auth, firestore) + val fixture = ScheduleTransactionFixture() + every { fixture.transaction.get(fixture.userRef) } answers { + userDocTask.exception?.let { throw it } + userDocTask.result + } + return fixture.repository } @Test @@ -59,7 +50,7 @@ class ScheduleRepositoryAddScheduleTest { ) val repository = repositoryWith(Tasks.forException(denied)) - val result = repository.addSchedule(Schedule(title = "혈압약 먹기")) + val result = repository.addSchedule(Schedule(id = "s1", title = "혈압약 먹기")) assertTrue(result is ScheduleRepository.ScheduleResult.Error) assertEquals( @@ -78,10 +69,10 @@ class ScheduleRepositoryAddScheduleTest { ) val repository = repositoryWith(Tasks.forException(unavailable)) - val result = repository.addSchedule(Schedule(title = "병원 가기")) + val result = repository.addSchedule(Schedule(id = "s1", title = "병원 가기")) assertEquals( - AppError.NetworkError, + AppError.OnlineWriteError, (result as ScheduleRepository.ScheduleResult.Error).error, ) } diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryShareCodeTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryShareCodeTest.kt index 2728dba..390ba2f 100644 --- a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryShareCodeTest.kt +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryShareCodeTest.kt @@ -11,6 +11,7 @@ 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.Transaction import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -59,93 +60,86 @@ class ScheduleRepositoryShareCodeTest { } @Test - fun `등록부 복구가 완료되기 전에 새 일정을 쓰지 않는다`() = + fun `신규 일정과 등록부는 같은 commit 확인까지 성공을 반환하지 않는다`() = runTest { - val pending = TaskCompletionSource() - every { codeRef.set(any()) } returns pending.task - - val result = async { repository.addSchedule(schedule) } + val f = ScheduleTransactionFixture(id = schedule.id) + val pending = TaskCompletionSource() + var saved: Any? = null + every { f.firestore.runTransaction(any>()) } answers { + saved = firstArg>().apply(f.transaction) + pending.task + } + val result = async { f.repository.addSchedule(schedule) } runCurrent() assertFalse(result.isCompleted) - verify(exactly = 0) { scheduleRef.set(any()) } - pending.setResult(null) - - assertEquals(ScheduleRepository.ScheduleResult.Success(schedule.id), result.await()) - verify(exactly = 1) { codeRef.set(mapOf("userId" to uid)) } - verify(exactly = 1) { scheduleRef.set(match { it.sharedCode == code && it.userId == uid }) } + verify(exactly = 1) { f.transaction.set(f.registryRef, mapOf("userId" to uid)) } + verify(exactly = 1) { f.transaction.set(f.scheduleRef, any()) } + verify(exactly = 0) { f.registryRef.set(any()) } + verify(exactly = 0) { f.scheduleRef.set(any()) } + pending.setResult(saved) + assertEquals(ScheduleRepository.ScheduleResult.Success(saved), result.await()) } @Test - fun `등록부 확인이 끝나야 편집을 저장한다`() = + fun `편집과 등록부도 같은 commit 확인까지 성공을 반환하지 않는다`() = runTest { - val pending = TaskCompletionSource() - every { codeRef.set(any()) } returns pending.task - - val result = async { repository.updateSchedule(schedule) } + val f = ScheduleTransactionFixture(id = schedule.id, server = schedule) + val pending = TaskCompletionSource() + every { f.firestore.runTransaction(any>()) } answers { + firstArg>().apply(f.transaction) + pending.task + } + val result = async { f.repository.updateSchedule(schedule) } runCurrent() assertFalse(result.isCompleted) - verify(exactly = 0) { scheduleRef.set(any()) } - verify(exactly = 0) { scheduleRef.update(any>()) } - pending.setResult(null) - + verify(exactly = 1) { f.transaction.set(f.registryRef, mapOf("userId" to uid)) } + verify(exactly = 1) { f.transaction.update(f.scheduleRef, any>()) } + verify(exactly = 0) { f.registryRef.set(any()) } + pending.setResult(Unit) assertEquals(ScheduleRepository.ScheduleResult.Success(Unit), result.await()) } @Test - fun `다른 계정의 이전 일정을 수정하려 하면 현재 계정으로 확인하고 쓰기를 막는다`() = + fun `다른 계정의 이전 일정은 등록부와 편집 쓰기를 모두 막는다`() = runTest { - val nextUser = mockk() - every { nextUser.uid } returns "next-user" - every { auth.currentUser } returns nextUser - every { codeRef.set(mapOf("userId" to "next-user")) } returns Tasks.forException(denied()) - - assertTrue(repository.updateSchedule(schedule) is ScheduleRepository.ScheduleResult.Error) - - verify(exactly = 1) { codeRef.set(mapOf("userId" to "next-user")) } - verify(exactly = 0) { scheduleRef.set(any()) } - verify(exactly = 0) { scheduleRef.update(any>()) } + val f = ScheduleTransactionFixture(id = schedule.id, server = schedule).apply { uid = "next-user" } + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.PermissionError), f.repository.updateSchedule(schedule)) + verify(exactly = 0) { f.transaction.set(any(), any()) } + verify(exactly = 0) { f.transaction.update(any(), any>()) } } @Test - fun `등록부 충돌은 저장과 편집을 막고 코드 유지 안내를 돌려준다`() = + fun `등록부 충돌로 거절된 commit은 저장과 편집을 성공으로 반환하지 않는다`() = runTest { - every { codeRef.set(any()) } returns Tasks.forException(denied()) - - val results = listOf(repository.addSchedule(schedule), repository.updateSchedule(schedule)) - - results.forEach { result -> - val error = (result as ScheduleRepository.ScheduleResult.Error).error - assertTrue(error is AppError.GeneralError) - assertTrue((error as AppError.GeneralError).message.contains("기존 코드는 유지됩니다")) - } - verify(exactly = 0) { scheduleRef.set(any()) } - verify(exactly = 0) { scheduleRef.update(any>()) } + val f = ScheduleTransactionFixture(id = schedule.id, server = schedule) + every { f.firestore.runTransaction(any>()) } returns Tasks.forException(denied()) + val results = listOf(f.repository.addSchedule(schedule), f.repository.updateSchedule(schedule)) + results.forEach { assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.PermissionError), it) } + verify(exactly = 0) { f.registryRef.set(any()) } + verify(exactly = 0) { f.scheduleRef.set(any()) } + verify(exactly = 0) { f.scheduleRef.update(any>()) } } @Test - fun `등록부 네트워크 실패도 일정 저장 없이 결과로 돌아온다`() = + fun `등록부를 포함한 commit의 연결 실패는 온라인 저장 안내로 돌아온다`() = runTest { - every { codeRef.set(any()) } returns - Tasks.forException( - FirebaseFirestoreException("offline", FirebaseFirestoreException.Code.UNAVAILABLE), - ) - - val result = repository.addSchedule(schedule) as ScheduleRepository.ScheduleResult.Error - - assertEquals(AppError.NetworkError, result.error) - verify(exactly = 0) { scheduleRef.set(any()) } + val f = ScheduleTransactionFixture(id = schedule.id) + every { f.firestore.runTransaction(any>()) } returns + Tasks.forException(FirebaseFirestoreException("offline", FirebaseFirestoreException.Code.UNAVAILABLE)) + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError), f.repository.addSchedule(schedule)) + verify(exactly = 0) { f.registryRef.set(any()) } + verify(exactly = 0) { f.scheduleRef.set(any()) } } @Test - fun `비공유 저장은 등록부를 호출하지 않는다`() = + fun `비공유 저장과 편집은 등록부를 호출하지 않는다`() = runTest { - every { snapshot.getString("shareCode") } returns "" - - assertTrue(repository.addSchedule(schedule) is ScheduleRepository.ScheduleResult.Success) - assertTrue(repository.updateSchedule(schedule.copy(sharedCode = "")) is ScheduleRepository.ScheduleResult.Success) - - verify(exactly = 0) { firestore.collection("shareCodes") } - verify(exactly = 1) { scheduleRef.set(match { it.sharedCode == "" }) } + val f = ScheduleTransactionFixture(id = schedule.id).apply { shareCode = "" } + assertTrue(f.repository.addSchedule(schedule) is ScheduleRepository.ScheduleResult.Success) + f.server = schedule.copy(sharedCode = "") + assertTrue(f.repository.updateSchedule(schedule) is ScheduleRepository.ScheduleResult.Success) + verify(exactly = 0) { f.transaction.set(f.registryRef, any()) } + verify(exactly = 1) { f.transaction.set(f.scheduleRef, match { it.sharedCode == "" }) } } @Test diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryTransactionTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryTransactionTest.kt new file mode 100644 index 0000000..656bace --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryTransactionTest.kt @@ -0,0 +1,185 @@ +package com.example.slowclock.data.remote.repository + +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.util.AppError +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.firebase.Timestamp +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestoreException +import com.google.firebase.firestore.Transaction +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.util.Date + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ScheduleRepositoryTransactionTest { + private val input = Schedule(id = "s1", title = "약", startTime = Timestamp(Date(1_900_000_000_000))) + + @Test + fun `신규 일정과 등록부를 같은 transaction으로 쓰고 저장된 모델을 반환한다`() = + runTest { + val f = ScheduleTransactionFixture() + val result = f.repository.addSchedule(input) as ScheduleRepository.ScheduleResult.Success + assertEquals("s1", result.data.id) + assertEquals("owner", result.data.userId) + assertEquals("ABC123", result.data.sharedCode) + verify(exactly = 1) { f.transaction.set(f.registryRef, mapOf("userId" to "owner")) } + verify(exactly = 1) { f.transaction.set(f.scheduleRef, result.data) } + verify(exactly = 0) { f.scheduleRef.set(any()) } + } + + @Test + fun `등록부가 빈 계정은 비공유 일정만 쓴다`() = + runTest { + val f = ScheduleTransactionFixture().apply { shareCode = "" } + val result = f.repository.addSchedule(input) as ScheduleRepository.ScheduleResult.Success + assertEquals("", result.data.sharedCode) + verify(exactly = 0) { f.transaction.set(f.registryRef, any()) } + } + + @Test + fun `같은 제출 ID의 늦은 성공을 재시도하면 최신 서버 문서를 반환하고 쓰지 않는다`() = + runTest { + val saved = input.copy(userId = "owner", sharedCode = "NEW123", completed = true, completedDates = listOf("2030-03-17")) + val f = ScheduleTransactionFixture(server = saved) + assertEquals(ScheduleRepository.ScheduleResult.Success(saved), f.repository.addSchedule(input)) + verify(exactly = 0) { f.transaction.set(any(), any()) } + verify(exactly = 0) { f.transaction.update(any(), any>()) } + } + + @Test + fun `같은 ID의 다른 내용이나 다른 소유자를 성공으로 처리하지 않는다`() = + runTest { + val f = ScheduleTransactionFixture(server = input.copy(userId = "owner", title = "다른 내용")) + assertTrue(f.repository.addSchedule(input) is ScheduleRepository.ScheduleResult.Error) + f.server = input.copy(userId = "other") + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.PermissionError), f.repository.addSchedule(input)) + verify(exactly = 0) { f.transaction.set(any(), any()) } + } + + @Test + fun `온라인 확인에 실패하면 생성 편집 삭제 완료 모두 연결 안내를 돌려준다`() = + runTest { + val f = ScheduleTransactionFixture(server = input.copy(userId = "owner")) + every { f.firestore.runTransaction(any>()) } returns + Tasks.forException( + FirebaseFirestoreException("unavailable", FirebaseFirestoreException.Code.UNAVAILABLE), + ) + val results = + listOf( + f.repository.addSchedule(input), + f.repository.updateSchedule(input), + f.repository.deleteSchedule("s1"), + f.repository.markScheduleAsCompleted("s1"), + ) + results.forEach { assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError), it) } + verify(exactly = 0) { f.scheduleRef.set(any()) } + verify(exactly = 0) { f.scheduleRef.update(any>()) } + verify(exactly = 0) { f.scheduleRef.delete() } + } + + @Test + fun `이미 지운 일정 삭제는 서버 부재 확인 뒤 성공하고 다시 쓰지 않는다`() = + runTest { + val f = ScheduleTransactionFixture() + assertEquals(ScheduleRepository.ScheduleResult.Success(Unit), f.repository.deleteSchedule("s1")) + verify(exactly = 1) { f.transaction.get(f.scheduleRef) } + verify(exactly = 0) { f.transaction.delete(any()) } + } + + @Test + fun `다른 소유자의 일정은 편집하거나 지우지 않는다`() = + runTest { + val f = ScheduleTransactionFixture(server = input.copy(userId = "other")) + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.PermissionError), f.repository.updateSchedule(input)) + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.PermissionError), f.repository.deleteSchedule("s1")) + verify(exactly = 0) { f.transaction.delete(any()) } + verify(exactly = 0) { f.transaction.update(any(), any>()) } + } + + @Test + fun `보호자 완료는 등록부를 쓰지 않고 회차 필드만 갱신한다`() = + runTest { + val f = ScheduleTransactionFixture(server = input.copy(userId = "other", sharedCode = "ABC123")) + val fields = slot>() + every { f.transaction.update(f.scheduleRef, capture(fields)) } returns f.transaction + assertEquals(ScheduleRepository.ScheduleResult.Success(Unit), f.repository.markScheduleAsCompleted("s1", true, "2030-03-17")) + assertEquals(setOf("completedDates", "updatedAt"), fields.captured.keys) + verify(exactly = 0) { f.transaction.set(any(), any()) } + } + + @Test + fun `없는 일정의 편집과 완료는 문서를 다시 만들지 않는다`() = + runTest { + val f = ScheduleTransactionFixture() + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.NotFoundError), f.repository.updateSchedule(input)) + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.NotFoundError), f.repository.markScheduleAsCompleted("s1")) + verify(exactly = 0) { f.transaction.set(any(), any()) } + verify(exactly = 0) { f.transaction.update(any(), any>()) } + } + + @Test + fun `취소는 오류 결과로 삼키지 않고 늦은 callback도 새 쓰기를 하지 않는다`() = + runTest { + val f = ScheduleTransactionFixture() + val pending = TaskCompletionSource() + val callback = slot>() + every { f.firestore.runTransaction(capture(callback)) } returns pending.task + val operation = async { f.repository.addSchedule(input) } + runCurrent() + operation.cancel() + try { + operation.await() + fail("CancellationException expected") + } catch (_: CancellationException) { + } + try { + callback.captured.apply(f.transaction) + fail("Cancelled callback must stop") + } catch (_: CancellationException) { + } + verify(exactly = 0) { f.transaction.set(any(), any()) } + } + + @Test + fun `취소 뒤 이미 제출한 commit이 성공해도 같은 ID 재시도는 중복 쓰지 않는다`() = + runTest { + val f = ScheduleTransactionFixture() + val pending = TaskCompletionSource() + var submitted: Schedule? = null + every { f.firestore.runTransaction(any>()) } answers { + submitted = firstArg>().apply(f.transaction) as Schedule + pending.task + } + val operation = async { f.repository.addSchedule(input) } + runCurrent() + operation.cancel() + f.server = requireNotNull(submitted) + pending.setResult(submitted) + every { f.firestore.runTransaction(any>()) } answers { + Tasks.forResult(firstArg>().apply(f.transaction)) + } + assertEquals(ScheduleRepository.ScheduleResult.Success(submitted), f.repository.addSchedule(input)) + verify(exactly = 1) { f.transaction.set(f.scheduleRef, any()) } + } + + @Test + fun `로그아웃이나 빈 제출 ID는 transaction을 시작하지 않는다`() = + runTest { + val f = ScheduleTransactionFixture() + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.InvalidDataError), f.repository.addSchedule(input.copy(id = ""))) + every { f.auth.currentUser } returns null + assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.AuthError), f.repository.addSchedule(input)) + verify(exactly = 0) { f.firestore.runTransaction(any>()) } + } +} diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryUpdateTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryUpdateTest.kt index 2d23817..356ce48 100644 --- a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryUpdateTest.kt +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryUpdateTest.kt @@ -25,25 +25,15 @@ import java.util.Date class ScheduleRepositoryUpdateTest { private val updatedFields = slot>() - private val document = mockk() + private lateinit var fixture: ScheduleTransactionFixture private fun repositoryWith(response: Task): ScheduleRepository { - val user = mockk() - every { user.uid } returns "owner" - val auth = mockk() - every { auth.currentUser } returns user - every { document.update(capture(updatedFields)) } returns response - val schedules = mockk() - every { schedules.document("s1") } returns document - val firestore = mockk() - every { firestore.collection("schedules") } returns schedules - every { firestore.collection("users") } returns mockk() - val registry = mockk() - val codeRef = mockk() - every { firestore.collection("shareCodes") } returns registry - every { registry.document("OLD123") } returns codeRef - every { codeRef.set(mapOf("userId" to "owner")) } returns Tasks.forResult(null) - return ScheduleRepository(auth, firestore) + fixture = ScheduleTransactionFixture(server = Schedule(id = "s1", userId = "owner", sharedCode = "NEW123")) + every { fixture.transaction.update(fixture.scheduleRef, capture(updatedFields)) } answers { + response.exception?.let { throw it } + fixture.transaction + } + return fixture.repository } @Test @@ -90,7 +80,7 @@ class ScheduleRepositoryUpdateTest { assertEquals(true, currentServer["recurring"]) assertEquals("weekly", currentServer["recurringType"]) assertTrue(currentServer["updatedAt"] is Timestamp) - verify(exactly = 0) { document.set(any()) } + verify(exactly = 0) { fixture.transaction.set(fixture.scheduleRef, any()) } } @Test @@ -116,6 +106,6 @@ class ScheduleRepositoryUpdateTest { val result = repository.updateSchedule(Schedule(id = "s1", title = "삭제된 일정")) assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.NotFoundError), result) - verify(exactly = 0) { document.set(any()) } + verify(exactly = 0) { fixture.transaction.set(fixture.scheduleRef, any()) } } } diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleTransactionFixture.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleTransactionFixture.kt new file mode 100644 index 0000000..c905b98 --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleTransactionFixture.kt @@ -0,0 +1,63 @@ +package com.example.slowclock.data.remote.repository + +import com.example.slowclock.data.model.Schedule +import com.google.android.gms.tasks.Tasks +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Transaction +import io.mockk.every +import io.mockk.mockk + +internal class ScheduleTransactionFixture( + val id: String = "s1", + var server: Schedule? = null, +) { + val auth = mockk() + val firestore = mockk() + val transaction = mockk() + val scheduleRef = mockk() + val userRef = mockk() + val registryRef = mockk() + val scheduleSnapshot = mockk() + val userSnapshot = mockk() + val repository: ScheduleRepository + var uid = "owner" + var shareCode = "ABC123" + + init { + val user = mockk() + every { auth.currentUser } returns user + every { user.uid } answers { uid } + val schedules = mockk() + val users = mockk() + val registry = mockk() + every { firestore.collection("schedules") } returns schedules + every { firestore.collection("users") } returns users + every { firestore.collection("shareCodes") } returns registry + every { schedules.document(id) } returns scheduleRef + every { users.document(any()) } returns userRef + every { registry.document(any()) } returns registryRef + every { transaction.get(scheduleRef) } returns scheduleSnapshot + every { transaction.get(userRef) } returns userSnapshot + every { scheduleSnapshot.exists() } answers { server != null } + every { scheduleSnapshot.toObject(Schedule::class.java) } answers { server } + every { scheduleSnapshot.getString("userId") } answers { server?.userId } + every { scheduleSnapshot.getString("sharedCode") } answers { server?.sharedCode } + every { userSnapshot.getString("shareCode") } answers { shareCode } + every { transaction.set(any(), any()) } returns transaction + every { transaction.update(any(), any>()) } returns transaction + every { transaction.delete(any()) } returns transaction + every { firestore.runTransaction(any>()) } answers { + try { + Tasks.forResult(firstArg>().apply(transaction)) + } catch (e: Exception) { + Tasks.forException(e) + } + } + repository = ScheduleRepository(auth, firestore) + } +} diff --git a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt index cde0afe..0d3f05e 100644 --- a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt @@ -66,13 +66,13 @@ fun ErrorCard( Icon( imageVector = when (error) { - is AppError.NetworkError -> Icons.Outlined.WifiOff + is AppError.OnlineWriteError, is AppError.NetworkError -> Icons.Outlined.WifiOff is AppError.TimeoutError -> Icons.Outlined.Schedule is AppError.AuthError -> Icons.Outlined.Lock is AppError.PermissionError -> Icons.Outlined.Block is AppError.InvalidDataError -> Icons.Outlined.EditNote is AppError.NotFoundError -> Icons.Outlined.SearchOff - is AppError.SaveError -> Icons.Outlined.SaveAs + is AppError.ScheduleConflictError, is AppError.SaveError -> Icons.Outlined.SaveAs is AppError.StorageFullError -> Icons.Outlined.Storage is AppError.GeneralError -> Icons.Outlined.WarningAmber }, @@ -86,12 +86,13 @@ fun ErrorCard( Text( text = when (error) { - is AppError.NetworkError -> "연결 문제" + is AppError.OnlineWriteError, is AppError.NetworkError -> "연결 문제" is AppError.TimeoutError -> "시간 초과" is AppError.AuthError -> "로그인 필요" is AppError.PermissionError -> "권한 없음" is AppError.InvalidDataError -> "입력 오류" is AppError.NotFoundError -> "찾을 수 없음" + is AppError.ScheduleConflictError -> "일정 확인 필요" is AppError.SaveError -> "저장 실패" is AppError.StorageFullError -> "저장공간 부족" is AppError.GeneralError -> "알 수 없는 오류" diff --git a/docs/firestore.md b/docs/firestore.md index c9ede3e..83beb34 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -8,7 +8,7 @@ |---|---|---|---| | `users/{uid}` | 이름, 이메일, 공유 코드, FCM 토큰, 생성·수정 시각 | 본인만 | 본인만 | | `publicProfiles/{uid}` | id, 이름 | 로그인한 사용자가 문서 하나씩(`get`). 목록 조회(`list`)는 아무도 못 함 | 본인만, 이 두 필드만 | -| `shareCodes/{code}` | 그 코드의 임자 uid | 아무도 못 함 | 만들기는 본인 이름으로 한 번만(이미 있으면 거절), 지우기는 임자만 | +| `shareCodes/{code}` | 그 코드의 임자 uid | 아무도 못 함 | 본인 소유로 만들기·동일값 확인, 임자의 삭제 및 없는 등록부 삭제 재시도 | | `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 그 `sharedCode` 의 감시자로 등록한 사람 | 소유자. 공유 일정은 감시자가 `completed`·`completedDates`·`updatedAt` 만 | | `notifications/{id}` | 알림 기록(소유자 uid) | 소유자 | 소유자 | | `shareCodeWatchers/{code}/tokens/{uid}` | 그 공유 코드를 보는 기기의 FCM 토큰 | 본인 문서만 | 본인 문서만 | @@ -33,6 +33,20 @@ Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 2. 공유 일정은 「공유로 표시됐는가」 가 아니라 「그 코드의 감시자로 등록한 사람인가」 로 연다. 등록 문서(`shareCodeWatchers/{code}/tokens/{uid}`)가 그 관계의 증거다. 이 문서는 읽기 권한의 근거이기도 해서, FCM 토큰을 못 받아도 등록 자체는 한다. 3. 읽기보다 쓰기를 더 좁게 본다. 남이 완료 표시를 뒤집을 수 있으면 어르신이 약을 이미 먹은 줄로 안다. 이 앱에서는 그쪽이 더 아픈 피해다. +## 일정 쓰기와 재시도 + +아래 계약은 온라인 쓰기 구현을 포함한 새 앱 버전에 적용된다. 구버전 앱에 남은 오프라인 쓰기 대기열을 제거하거나, 이미 대기 중인 요청을 같은 ID 재시도로 바꾸지는 않는다. 구버전 잔존 여부와 재연결 후 기존 요청의 처리를 출시 검증에 포함한다. + +일정 생성·편집·삭제·완료는 온라인 transaction으로 처리한다. 서버가 결과를 확인한 뒤에 저장 성공을 표시하고 알람 후처리를 한다. 연결이 끊기면 연결 필요 안내와 재시도를 제공하며, 입력은 그대로 유지한다. 조회용 오프라인 캐시는 유지하지만 이 네 가지 쓰기를 오프라인 대기열에 넣지는 않는다. transaction callback에는 UI나 알람 처리를 넣지 않는다. + +새 일정 ID는 제출 전에 만들어 `SavedStateHandle`에 입력과 함께 보존한다. 같은 ID의 문서가 이미 있으면 소유자와 편집 필드를 비교하며, 같은 요청만 서버의 최신 문서를 반환한다. 다른 내용은 덮어쓰거나 성공으로 처리하지 않고 목록에서 확인하도록 안내한다. 코루틴 취소가 이미 전송한 commit을 철회하지는 않으므로, 재시도에서도 같은 ID를 쓰는 것이 필요하다. + +`SavedStateHandle`은 시스템이 같은 task를 복원하는 범위다. 강제 종료·최근 앱 목록에서 제거·재부팅으로 task가 사라진 경우의 입력 복원까지 보장하지 않는다. 백그라운드 상태에서 저장되지 않은 최신 값에도 한계가 있다. 이 범위를 넘어서는 내구성 있는 작성 중 보관함은 별도다. + +공유 등록부 복구와 일정 쓰기는 같은 transaction에서 수행하고 규칙은 `getAfter`로 최종 소유 관계를 확인한다. 첫 제출의 고정 ID가 비어 있는지 확인할 수 있도록 로그인한 요청의 미존재 일정 `get`만 허용한다. 기존 타인 일정과 목록 및 등록부 읽기는 계속 제한한다. 완료 실패는 화면의 임시 표시를 되돌리며, 그 사이 도착한 새 서버 목록은 덮지 않는다. 완료·시간표 화면은 로그아웃 시 이전 목록을 비우고 로그인 필요 안내를 표시한다. 재로그인하면 선택한 날짜의 구독을 다시 시작하며, 이전 로그인 세대의 완료 응답은 새 화면을 변경하지 않는다. + +근거: [Firestore transactions](https://firebase.google.com/docs/firestore/manage-data/transactions), [SavedStateHandle 복원 범위](https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate), [Task.await 취소](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-play-services/kotlinx.coroutines.tasks/await.html). + ## 서버가 관리자 권한으로 읽는 곳 Cloud Functions 는 Admin SDK 로 동작해 규칙을 거치지 않는다. 규칙만 읽으면 이 경로를 놓친다. @@ -53,6 +67,12 @@ npm test ## 배포 +2026-09-06 작업 기준으로 운영 규칙은 배포하지 않았으며, 운영 공유 등록부의 inventory와 충돌 정리도 아직 확인하지 않았다. 저장소·에뮬레이터 검증 통과를 운영 전환 완료로 간주하지 않는다. + +1. 기존 사용자·일정의 공유 코드와 `shareCodes` 소유 관계를 점검한다. 누락·중복·소유자 불일치를 구분하고, 기존 사용자의 코드가 바뀌거나 다른 소유자의 등록부를 덮지 않도록 충돌을 정리한다. 새 앱의 자동 복구만으로 구버전 사용자의 등록부까지 정리됐다고 가정하지 않는다. +2. 점검·정리 결과를 확인한 뒤 아래 명령으로 새 규칙을 적용한다. 새 앱을 구규칙에 먼저 연결하면 등록부 동일값 확인이나 첫 transaction의 미존재 일정 조회가 거절될 수 있다. 반대로 등록부가 맞지 않는 구버전 앱은 새 규칙 아래 공유 일정 생성·편집·완료가 거절될 수 있다. +3. 새 규칙 아래 새 앱으로 로그인·생성·편집·삭제·완료·공유를 기기에서 검증한 뒤 앱을 배포한다. 구버전 잔존과 기존 오프라인 요청의 재연결 결과도 함께 기록한다. + ```bash firebase deploy --only firestore:rules firebase deploy --only firestore:indexes diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt index b53e09f..54d7831 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt @@ -1,6 +1,7 @@ package com.example.slowclock.ui.addschedule import android.util.Log +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.example.slowclock.core.alarm.AlarmScheduler import com.example.slowclock.data.model.Schedule @@ -9,10 +10,14 @@ import com.example.slowclock.ui.mvi.MviViewModel import com.example.slowclock.util.AppError import com.google.firebase.Timestamp import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.Calendar +import java.util.UUID import javax.inject.Inject private const val MAX_TITLE_LENGTH = 100 @@ -24,7 +29,10 @@ class AddScheduleViewModel constructor( private val scheduleRepository: ScheduleRepository, private val alarmScheduler: AlarmScheduler, - ) : MviViewModel(AddScheduleUiState()) { + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), + ) : MviViewModel(savedStateHandle.restoreDraft()) { + private val draftId = + savedStateHandle.get(DRAFT_ID) ?: UUID.randomUUID().toString().also { savedStateHandle[DRAFT_ID] = it } private var editLoadJob: Job? = null override fun onIntent(intent: AddScheduleIntent) { @@ -96,6 +104,7 @@ class AddScheduleViewModel dispatch(AddScheduleReducerEvent.SavedConsumed) } } + if (!currentState.isEditMode) savedStateHandle.saveDraft(currentState) } override fun reduce( @@ -208,6 +217,8 @@ class AddScheduleViewModel try { alarmScheduler.schedule(schedule) dispatch(AddScheduleReducerEvent.Saved) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { Log.e(TAG, "일정 저장 후 알람 예약 실패", error) dispatch(AddScheduleReducerEvent.AlarmFailed(schedule)) @@ -259,19 +270,20 @@ class AddScheduleViewModel viewModelScope.launch { val result = if (state.isEditMode) { - scheduleRepository.updateSchedule(schedule).map { schedule.id } + scheduleRepository.updateSchedule(schedule).map { schedule } } else { scheduleRepository.addSchedule(schedule) } + currentCoroutineContext().ensureActive() when (result) { is ScheduleRepository.ScheduleResult.Success -> { - // 새 일정은 Firestore 가 준 ID 로 알람을 건다. 빈 ID 로 걸면 모든 새 일정이 같은 요청 코드를 쓴다. - reserveSavedAlarm(schedule.copy(id = result.data)) + // 같은 ID 재시도에서도 서버가 돌려준 최신 일정으로 알람을 예약한다. + reserveSavedAlarm(result.data) } is ScheduleRepository.ScheduleResult.Error -> { Log.e(TAG, "저장 실패: ${result.error.message}") - dispatch(AddScheduleReducerEvent.Failed(result.error, canRetry = true)) + dispatch(AddScheduleReducerEvent.Failed(result.error, canRetry = result.error !is AppError.ScheduleConflictError)) } } } @@ -308,7 +320,7 @@ class AddScheduleViewModel } private fun AddScheduleUiState.toSchedule(title: String): Schedule { - val base = if (isEditMode) requireNotNull(editingSchedule) else Schedule() + val base = if (isEditMode) requireNotNull(editingSchedule) else Schedule(id = draftId) return base.copy( title = title, description = description.trim(), @@ -327,6 +339,7 @@ class AddScheduleViewModel private companion object { const val TAG = "AddScheduleViewModel" + const val DRAFT_ID = "new_schedule_id" } } diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/ScheduleDraftState.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/ScheduleDraftState.kt new file mode 100644 index 0000000..b231ce3 --- /dev/null +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/ScheduleDraftState.kt @@ -0,0 +1,41 @@ +package com.example.slowclock.ui.addschedule + +import androidx.lifecycle.SavedStateHandle +import java.util.Calendar + +/** task가 시스템에 의해 복원될 때의 가벼운 입력값만 보존한다. 원격 저장 완료를 의미하지 않는다. */ +internal fun SavedStateHandle.restoreDraft(): AddScheduleUiState { + val start = Calendar.getInstance().apply { get("draft_start")?.let { timeInMillis = it } } + val end = get("draft_end")?.let { millis -> Calendar.getInstance().apply { timeInMillis = millis } } + return AddScheduleUiState( + title = get("draft_title").orEmpty(), + description = get("draft_description").orEmpty(), + selectedTime = start, + endTime = end, + startTimeInput = + ScheduleTimeInput( + get("draft_start_hour") ?: start.get(Calendar.HOUR_OF_DAY).toString(), + get("draft_start_minute") ?: start.get(Calendar.MINUTE).toString(), + ), + endTimeInput = + ScheduleTimeInput( + get("draft_end_hour") ?: end?.get(Calendar.HOUR_OF_DAY)?.toString().orEmpty(), + get("draft_end_minute") ?: end?.get(Calendar.MINUTE)?.toString().orEmpty(), + ), + recurring = get("draft_recurring") ?: false, + recurringType = get("draft_recurring_type") ?: "daily", + ) +} + +internal fun SavedStateHandle.saveDraft(state: AddScheduleUiState) { + this["draft_title"] = state.title + this["draft_description"] = state.description + this["draft_start"] = state.selectedTime.timeInMillis + this["draft_end"] = state.endTime?.timeInMillis + this["draft_start_hour"] = state.startTimeInput.hour + this["draft_start_minute"] = state.startTimeInput.minute + this["draft_end_hour"] = state.endTimeInput.hour + this["draft_end_minute"] = state.endTimeInput.minute + this["draft_recurring"] = state.recurring + this["draft_recurring_type"] = state.recurringType +} diff --git a/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleDraftTest.kt b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleDraftTest.kt new file mode 100644 index 0000000..6c48038 --- /dev/null +++ b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleDraftTest.kt @@ -0,0 +1,135 @@ +package com.example.slowclock.ui.addschedule + +import androidx.lifecycle.SavedStateHandle +import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.data.remote.repository.ScheduleRepository +import com.example.slowclock.util.AppError +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AddScheduleDraftTest { + private val repository = mockk() + private val alarms = mockk(relaxed = true) + + @Before fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After fun tearDown() { + Dispatchers.resetMain() + } + + @Test fun `연결 실패와 입력 변경 후에도 같은 제출 ID로 재시도한다`() = + runTest { + val requests = mutableListOf() + coEvery { repository.addSchedule(capture(requests)) } returns ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError) + val handle = SavedStateHandle() + val vm = AddScheduleViewModel(repository, alarms, handle) + val id = requireNotNull(handle.get("new_schedule_id")) + vm.onIntent(AddScheduleIntent.UpdateTitle("약")) + vm.onIntent(AddScheduleIntent.Save) + assertFalse(vm.uiState.value.isLoading) + assertTrue(vm.uiState.value.canRetry) + assertEquals("약", vm.uiState.value.title) + vm.onIntent(AddScheduleIntent.UpdateDescription("식후")) + vm.onIntent(AddScheduleIntent.Retry) + assertEquals(listOf(id, id), requests.map { it.id }) + verify(exactly = 0) { alarms.schedule(any()) } + } + + @Test fun `시스템 화면 복원에서는 ID와 유효하지 않은 입력까지 복원한다`() = + runTest { + val handle = SavedStateHandle() + val vm = AddScheduleViewModel(repository, alarms, handle) + vm.onIntent(AddScheduleIntent.UpdateTitle("약")) + vm.onIntent(AddScheduleIntent.UpdateDescription("설명")) + vm.onIntent(AddScheduleIntent.UpdateTimeInput(ScheduleTimeInput("2", ""))) + val restoredHandle = SavedStateHandle(handle.keys().associateWith { handle.get(it) }) + val restored = AddScheduleViewModel(repository, alarms, restoredHandle) + assertEquals(handle.get("new_schedule_id"), restoredHandle.get("new_schedule_id")) + assertEquals("약", restored.uiState.value.title) + assertEquals("설명", restored.uiState.value.description) + assertEquals(ScheduleTimeInput("2", ""), restored.uiState.value.startTimeInput) + assertFalse(restored.uiState.value.canSave) + coVerify(exactly = 0) { repository.addSchedule(any()) } + } + + @Test fun `복원 후 재시도의 서버 모델로 알람 후처리를 한다`() = + runTest { + val handle = SavedStateHandle(mapOf("new_schedule_id" to "stable-id", "draft_title" to "약")) + val saved = Schedule(id = "stable-id", title = "약", completed = true) + coEvery { repository.addSchedule(match { it.id == "stable-id" }) } returns ScheduleRepository.ScheduleResult.Success(saved) + val vm = AddScheduleViewModel(repository, alarms, handle) + vm.onIntent(AddScheduleIntent.Save) + verify(exactly = 1) { alarms.schedule(saved) } + assertTrue(vm.uiState.value.isSaved) + } + + @Test fun `같은 ID의 다른 내용은 반복 저장 대신 목록 확인을 안내한다`() = + runTest { + coEvery { repository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Error(AppError.ScheduleConflictError) + val vm = AddScheduleViewModel(repository, alarms, SavedStateHandle()) + vm.onIntent(AddScheduleIntent.UpdateTitle("수정한 입력")) + vm.onIntent(AddScheduleIntent.Save) + assertEquals(AppError.ScheduleConflictError, vm.uiState.value.error) + assertFalse(vm.uiState.value.canRetry) + assertEquals("수정한 입력", vm.uiState.value.title) + verify(exactly = 0) { alarms.schedule(any()) } + } + + @Test fun `예약 실패 뒤 시스템 복원에서도 같은 제출 ID와 서버 모델을 쓴다`() = + runTest { + val handle = SavedStateHandle() + val requests = mutableListOf() + var saved: Schedule? = null + coEvery { repository.addSchedule(capture(requests)) } answers { + if (saved == null) saved = firstArg().copy(sharedCode = "SERVER", completed = true) + ScheduleRepository.ScheduleResult.Success(requireNotNull(saved)) + } + every { alarms.schedule(any()) } throws IllegalStateException("alarm unavailable") + val vm = AddScheduleViewModel(repository, alarms, handle) + vm.onIntent(AddScheduleIntent.UpdateTitle("약")) + vm.onIntent(AddScheduleIntent.Save) + assertEquals(saved, vm.uiState.value.pendingAlarmSchedule) + val restoredHandle = SavedStateHandle(handle.keys().associateWith { handle.get(it) }) + val restored = AddScheduleViewModel(repository, alarms, restoredHandle) + justRun { alarms.schedule(any()) } + restored.onIntent(AddScheduleIntent.Save) + assertEquals(2, requests.size) + assertEquals(requests.first().id, requests.last().id) + verify(exactly = 2) { alarms.schedule(requireNotNull(saved)) } + assertTrue(restored.uiState.value.isSaved) + } + + @Test fun `알람 후처리 취소를 예약 실패 안내로 삼키지 않는다`() = + runTest { + coEvery { repository.addSchedule(any()) } answers { ScheduleRepository.ScheduleResult.Success(firstArg()) } + every { alarms.schedule(any()) } throws CancellationException("cancelled") + val vm = AddScheduleViewModel(repository, alarms, SavedStateHandle()) + vm.onIntent(AddScheduleIntent.UpdateTitle("약")) + vm.onIntent(AddScheduleIntent.Save) + assertNull(vm.uiState.value.pendingAlarmSchedule) + assertNull(vm.uiState.value.error) + assertFalse(vm.uiState.value.isSaved) + } +} diff --git a/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt index 0dcf92a..b3662a9 100644 --- a/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt +++ b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt @@ -47,7 +47,11 @@ class AddScheduleViewModelTest { @Test fun `서버 저장 후 알람 실패 재시도는 같은 ID를 예약하고 서버 저장을 반복하지 않는다`() = runTest { - coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Success("saved-id") + coEvery { scheduleRepository.addSchedule(any()) } answers { + ScheduleRepository.ScheduleResult.Success( + firstArg().copy(id = "saved-id", sharedCode = "SERVER", completed = true), + ) + } every { alarmScheduler.schedule(any()) } throws IllegalStateException("quota") val viewModel = createViewModel() viewModel.onIntent(AddScheduleIntent.UpdateTitle("약")) @@ -73,13 +77,24 @@ class AddScheduleViewModelTest { assertTrue(viewModel.uiState.value.isSaved) assertNull(viewModel.uiState.value.pendingAlarmSchedule) coVerify(exactly = 1) { scheduleRepository.addSchedule(any()) } - verify(exactly = 2) { alarmScheduler.schedule(match { it.id == "saved-id" && it.title == "약" }) } + verify(exactly = 2) { + alarmScheduler.schedule( + match { + it.id == "saved-id" && it.title == "약" && it.sharedCode == "SERVER" && + it.completed + }, + ) + } } @Test fun `알람 실패 안내를 닫으면 저장된 일정으로 돌아가고 재저장하지 않는다`() = runTest { - coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Success("saved-id") + coEvery { scheduleRepository.addSchedule(any()) } answers { + ScheduleRepository.ScheduleResult.Success( + firstArg().copy(id = "saved-id", sharedCode = "SERVER", completed = true), + ) + } every { alarmScheduler.schedule(any()) } throws IllegalStateException("quota") val viewModel = createViewModel() viewModel.onIntent(AddScheduleIntent.UpdateTitle("약")) @@ -130,7 +145,8 @@ class AddScheduleViewModelTest { @Test fun `새 일정을 저장하면 Firestore 가 준 ID 로 알람을 걸고 저장 신호를 낸다`() = runTest { - coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Success("new-id") + coEvery { scheduleRepository.addSchedule(any()) } answers + { ScheduleRepository.ScheduleResult.Success(firstArg().copy(id = "new-id")) } val scheduled = slot() justRun { alarmScheduler.schedule(capture(scheduled)) } val viewModel = createViewModel() @@ -187,7 +203,7 @@ class AddScheduleViewModelTest { fun `저장이 실패하면 재시도 가능한 오류를 두고 Retry 가 다시 저장한다`() = runTest { coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Error(AppError.NetworkError) andThen - ScheduleRepository.ScheduleResult.Success("new-id") + ScheduleRepository.ScheduleResult.Success(Schedule(id = "new-id")) val viewModel = createViewModel() viewModel.onIntent(AddScheduleIntent.UpdateTitle("약 먹기")) diff --git a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt index af394df..39f618e 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt @@ -43,5 +43,9 @@ sealed interface DoneReducerEvent : ReducerEvent { val scheduleId: String, ) : DoneReducerEvent + data class Restored( + val schedule: Schedule, + ) : DoneReducerEvent + data object ErrorConsumed : DoneReducerEvent } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt index b1d8852..7f7bc70 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt @@ -2,12 +2,16 @@ package com.example.slowclock.ui.done import android.util.Log import androidx.lifecycle.viewModelScope +import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.ui.mvi.MviViewModel +import com.example.slowclock.util.AppError import com.example.slowclock.util.toAppError import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.util.Calendar import javax.inject.Inject @@ -18,14 +22,26 @@ class DoneViewModel @Inject constructor( private val scheduleRepository: ScheduleRepository, + private val authRepository: AuthRepository, ) : MviViewModel(DoneUiState()) { private var scheduleJob: Job? = null + private var scheduleRevision = 0L + private var userGeneration = 0L + private val pendingCompletions = mutableSetOf() /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ private var subscribedDay: String = "" init { - observeTodaySchedules() + viewModelScope.launch { + authRepository.observeCurrentUid().distinctUntilChanged().collect { + userGeneration++ + scheduleRevision++ + scheduleJob?.cancel() + dispatch(DoneReducerEvent.Loaded(emptyList())) + observeTodaySchedules() + } + } } override fun onIntent(intent: DoneIntent) { @@ -72,6 +88,19 @@ class DoneViewModel ) } + is DoneReducerEvent.Restored -> { + state.copy( + schedules = + state.schedules.map { + if (it.id == event.schedule.id && it.occurrenceDate == event.schedule.occurrenceDate) { + it.copy(completed = event.schedule.completed) + } else { + it + } + }, + ) + } + DoneReducerEvent.ErrorConsumed -> { state.copy(error = null) } @@ -79,6 +108,14 @@ class DoneViewModel private fun observeTodaySchedules() { scheduleJob?.cancel() + scheduleRevision++ + val uid = authRepository.currentUid + if (uid == null) { + dispatch(DoneReducerEvent.Loaded(emptyList())) + dispatch(DoneReducerEvent.Failed(AppError.AuthError)) + return + } + val generation = userGeneration subscribedDay = todayKey() scheduleJob = viewModelScope.launch { @@ -87,8 +124,15 @@ class DoneViewModel .observeSchedulesForDate(Calendar.getInstance(), today = true) .catch { e -> Log.e(TAG, "일정 구독 실패", e) - dispatch(DoneReducerEvent.Failed(e.toAppError())) - }.collect { dispatch(DoneReducerEvent.Loaded(it)) } + if (authRepository.currentUid == uid && generation == userGeneration) { + dispatch(DoneReducerEvent.Failed(e.toAppError())) + } + }.collect { + if (authRepository.currentUid == uid && generation == userGeneration) { + scheduleRevision++ + dispatch(DoneReducerEvent.Loaded(it)) + } + } } } @@ -110,18 +154,33 @@ class DoneViewModel } private fun toggleComplete(scheduleId: String) { + val uid = authRepository.currentUid ?: return + val generation = userGeneration val schedule = currentState.schedules.find { it.id == scheduleId } ?: return + val key = "$uid:$generation:$scheduleId" + if (!pendingCompletions.add(key)) return + val revision = scheduleRevision dispatch(DoneReducerEvent.Toggled(scheduleId)) + + fun restoreIfCurrent() { + if (authRepository.currentUid == uid && generation == userGeneration && revision == scheduleRevision) { + dispatch(DoneReducerEvent.Restored(schedule)) + } + } viewModelScope.launch { - val result = - scheduleRepository.markScheduleAsCompleted( - scheduleId = scheduleId, - completed = !schedule.completed, - occurrenceDate = schedule.occurrenceDate, - ) - if (result is ScheduleRepository.ScheduleResult.Error) { - Log.e(TAG, "완료 상태 변경 실패: ${result.error.message}") - dispatch(DoneReducerEvent.Failed(result.error)) + try { + val result = scheduleRepository.markScheduleAsCompleted(scheduleId, !schedule.completed, schedule.occurrenceDate) + if (result is ScheduleRepository.ScheduleResult.Error && authRepository.currentUid == uid && + generation == userGeneration + ) { + restoreIfCurrent() + dispatch(DoneReducerEvent.Failed(result.error)) + } + } catch (e: CancellationException) { + restoreIfCurrent() + throw e + } finally { + pendingCompletions.remove(key) } } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt index 80c202b..7395d41 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt @@ -122,6 +122,12 @@ sealed interface MainReducerEvent : ReducerEvent { val nowMillis: Long, ) : MainReducerEvent + data class CompletionRestored( + val schedule: Schedule, + val shared: Boolean, + val nowMillis: Long, + ) : MainReducerEvent + data class DetailShown( val schedule: Schedule, ) : MainReducerEvent diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt index 16a4245..6a73b37 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -11,6 +11,7 @@ import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.ui.mvi.MviViewModel import com.example.slowclock.util.toAppError import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext @@ -27,9 +28,9 @@ import java.util.UUID import javax.inject.Inject /** - * 메인 화면. 오늘 일정은 Firestore 리스너로 받는다. 완료 토글·삭제는 낙관적으로 먼저 반영하고, - * 실패하면 리스너가 서버 상태로 되돌린다. 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 - * 다시 구독한다. + * 메인 화면. 오늘 일정은 Firestore 리스너로 받는다. 완료는 임시 표시하고 삭제는 서버 확인 후 반영한다. + * 완료 실패는 최신 서버 목록을 덮지 않는 범위에서 표시를 되돌린다. + * 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 다시 구독한다. */ @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -43,6 +44,10 @@ class MainViewModel private val alarmScheduler: AlarmScheduler, ) : MviViewModel(MainUiState()) { private var scheduleJob: Job? = null + private var completionUserGeneration = 0L + private var ownRevision = 0L + private var sharedRevision = 0L + private val pendingCompletions = mutableSetOf() private var deleteJob: Job? = null /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ @@ -214,6 +219,20 @@ class MainViewModel state.copy(isLoading = false, error = event.error, canRetry = event.canRetry, failedDelete = null) } + is MainReducerEvent.CompletionRestored -> { + fun restore(schedule: Schedule): Schedule = + if (schedule.id == event.schedule.id && schedule.occurrenceDate == event.schedule.occurrenceDate) { + schedule.copy(completed = event.schedule.completed) + } else { + schedule + } + if (event.shared) { + state.copy(sharedReminders = state.sharedReminders.map(::restore)) + } else { + state.withSchedules(state.todaySchedules.map(::restore), event.nowMillis) + } + } + is MainReducerEvent.CompletionToggled -> { state.withSchedules( state.todaySchedules.map { if (it.id == event.scheduleId) it.copy(completed = !it.completed) else it }, @@ -351,6 +370,9 @@ class MainViewModel alarmSyncJob?.cancel() alarmSyncJob = null alarmSyncedFor = null + completionUserGeneration++ + ownRevision++ + sharedRevision++ deleteJob?.cancel() deleteJob = null dispatch(MainReducerEvent.UserResolved(uid.orEmpty())) @@ -404,6 +426,7 @@ class MainViewModel Log.e(TAG, "일정 구독 실패", e) dispatch(MainReducerEvent.LoadFailed(e.toAppError(), canRetry = true)) }.collect { schedules -> + ownRevision++ dispatch(MainReducerEvent.SchedulesLoaded(schedules, System.currentTimeMillis())) } } @@ -440,6 +463,7 @@ class MainViewModel } } }.collect { reminders -> + sharedRevision++ val today = Calendar.getInstance() val todayReminders = reminders.filter { it.startTime.toDate().isSameDay(today) } dispatch(MainReducerEvent.SharedRemindersLoaded(todayReminders)) @@ -467,18 +491,45 @@ class MainViewModel } private fun toggleComplete(scheduleId: String) { - val schedule = currentState.todaySchedules.find { it.id == scheduleId } ?: return - dispatch(MainReducerEvent.CompletionToggled(scheduleId, System.currentTimeMillis())) + currentState.todaySchedules.find { it.id == scheduleId }?.let { toggleCompletion(it, shared = false) } + } + + private fun toggleCompletion( + schedule: Schedule, + shared: Boolean, + ) { + val uid = authRepository.currentUid ?: return + val generation = completionUserGeneration + val key = "$uid:$generation:${schedule.id}" + if (!pendingCompletions.add(key)) return + val revision = if (shared) sharedRevision else ownRevision + if (shared) { + dispatch(MainReducerEvent.SharedReminderToggled(schedule.id)) + } else { + dispatch(MainReducerEvent.CompletionToggled(schedule.id, System.currentTimeMillis())) + } + + fun restoreIfCurrent() { + if (authRepository.currentUid == uid && generation == completionUserGeneration && + revision == if (shared) sharedRevision else ownRevision + ) { + dispatch(MainReducerEvent.CompletionRestored(schedule, shared, System.currentTimeMillis())) + } + } viewModelScope.launch { - val result = - scheduleRepository.markScheduleAsCompleted( - scheduleId = scheduleId, - completed = !schedule.completed, - occurrenceDate = schedule.occurrenceDate, - ) - if (result is ScheduleRepository.ScheduleResult.Error) { - Log.e(TAG, "완료 상태 변경 실패: ${result.error.message}") - dispatch(MainReducerEvent.LoadFailed(result.error, canRetry = false)) + try { + val result = scheduleRepository.markScheduleAsCompleted(schedule.id, !schedule.completed, schedule.occurrenceDate) + if (result is ScheduleRepository.ScheduleResult.Error && authRepository.currentUid == uid && + generation == completionUserGeneration + ) { + restoreIfCurrent() + dispatch(MainReducerEvent.LoadFailed(result.error, canRetry = false)) + } + } catch (e: CancellationException) { + restoreIfCurrent() + throw e + } finally { + pendingCompletions.remove(key) } } } @@ -508,27 +559,7 @@ class MainViewModel } private fun toggleSharedReminderComplete(scheduleId: String) { - val reminder = currentState.sharedReminders.find { it.id == scheduleId } ?: return - dispatch(MainReducerEvent.SharedReminderToggled(scheduleId)) - viewModelScope.launch { - // 공유 일정이 바뀌면 Firestore 트리거(sendFcmToShareCodeWatchers)가 감시자에게 알린다. - // 클라이언트가 남의 FCM 토큰을 읽어 직접 보내던 경로는 지웠다(#93). - val result = - scheduleRepository.markScheduleAsCompleted( - scheduleId = scheduleId, - completed = !reminder.completed, - occurrenceDate = reminder.occurrenceDate, - ) - when (result) { - is ScheduleRepository.ScheduleResult.Success -> { - Unit - } - - is ScheduleRepository.ScheduleResult.Error -> { - dispatch(MainReducerEvent.LoadFailed(result.error, canRetry = false)) - } - } - } + currentState.sharedReminders.find { it.id == scheduleId }?.let { toggleCompletion(it, shared = true) } } private companion object { diff --git a/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineViewModel.kt index 8af0b1a..b04ba8f 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineViewModel.kt @@ -2,12 +2,15 @@ package com.example.slowclock.ui.timeline import android.util.Log import androidx.lifecycle.viewModelScope +import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.ui.mvi.MviViewModel +import com.example.slowclock.util.AppError import com.example.slowclock.util.toAppError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.util.Calendar import javax.inject.Inject @@ -18,11 +21,18 @@ class TimelineViewModel @Inject constructor( private val scheduleRepository: ScheduleRepository, + private val authRepository: AuthRepository, ) : MviViewModel(TimelineUiState()) { private var scheduleJob: Job? = null + private var subscriptionGeneration = 0L init { - observeSchedules(currentState.selectedDate) + viewModelScope.launch { + authRepository.observeCurrentUid().distinctUntilChanged().collect { + dispatch(TimelineReducerEvent.Loaded(emptyList())) + observeSchedules(currentState.selectedDate) + } + } } override fun onIntent(intent: TimelineIntent) { @@ -73,6 +83,13 @@ class TimelineViewModel private fun observeSchedules(date: Calendar) { scheduleJob?.cancel() + val generation = ++subscriptionGeneration + val uid = authRepository.currentUid + if (uid == null) { + dispatch(TimelineReducerEvent.Loaded(emptyList())) + dispatch(TimelineReducerEvent.Failed(AppError.AuthError)) + return + } scheduleJob = viewModelScope.launch { dispatch(TimelineReducerEvent.Loading) @@ -80,8 +97,14 @@ class TimelineViewModel .observeSchedulesForDate(date) .catch { e -> Log.e(TAG, "일정 구독 실패", e) - dispatch(TimelineReducerEvent.Failed(e.toAppError())) - }.collect { dispatch(TimelineReducerEvent.Loaded(it)) } + if (authRepository.currentUid == uid && generation == subscriptionGeneration) { + dispatch(TimelineReducerEvent.Failed(e.toAppError())) + } + }.collect { + if (authRepository.currentUid == uid && generation == subscriptionGeneration) { + dispatch(TimelineReducerEvent.Loaded(it)) + } + } } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt index 04cb2a9..a2c48b6 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt @@ -1,15 +1,21 @@ package com.example.slowclock.ui.done import com.example.slowclock.data.model.Schedule +import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.util.AppError import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -23,11 +29,15 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class DoneViewModelTest { private val scheduleRepository = mockk() + private val authRepository = mockk() + private val currentUid = MutableStateFlow("owner") private val todaySchedules = MutableStateFlow>(emptyList()) @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) + every { authRepository.currentUid } answers { currentUid.value } + every { authRepository.observeCurrentUid() } returns currentUid every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns todaySchedules } @@ -45,24 +55,24 @@ class DoneViewModelTest { Schedule(id = "b", title = "b"), ) - val state = DoneViewModel(scheduleRepository).uiState.value + val state = DoneViewModel(scheduleRepository, authRepository).uiState.value assertEquals(listOf("a"), state.completed.map { it.id }) assertEquals(listOf("b"), state.remaining.map { it.id }) } @Test - fun `토글은 먼저 반영하고 실패하면 오류를 둔다`() = + fun `토글 실패는 완료 표시를 되돌리고 오류를 둔다`() = runTest { todaySchedules.value = listOf(Schedule(id = "a", title = "a")) coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } returns ScheduleRepository.ScheduleResult.Error(AppError.NetworkError) - val viewModel = DoneViewModel(scheduleRepository) + val viewModel = DoneViewModel(scheduleRepository, authRepository) viewModel.onIntent(DoneIntent.ToggleComplete("a")) assertEquals( - listOf("a"), + emptyList(), viewModel.uiState.value.completed .map { it.id }, ) @@ -73,16 +83,126 @@ class DoneViewModelTest { assertNull(viewModel.uiState.value.error) } + @Test + fun `완료 목록의 늦은 실패도 새 서버 내용을 덮지 않는다`() = + runTest { + val original = Schedule(id = "a", title = "a") + todaySchedules.value = listOf(original) + val pending = CompletableDeferred>() + coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } coAnswers { pending.await() } + val vm = DoneViewModel(scheduleRepository, authRepository) + vm.onIntent(DoneIntent.ToggleComplete("a")) + vm.onIntent(DoneIntent.ToggleComplete("a")) + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("a", true) } + todaySchedules.value = listOf(original.copy(completed = true, title = "새 서버 내용")) + pending.complete(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError)) + assertEquals( + "새 서버 내용", + vm.uiState.value.completed + .single() + .title, + ) + } + @Test fun `반복 일정 완료와 취소는 표시된 발생일을 전달한다`() = runTest { todaySchedules.value = listOf(Schedule(id = "a", title = "산책", occurrenceDate = "2026-09-06")) coEvery { scheduleRepository.markScheduleAsCompleted("a", any(), "2026-09-06") } returns ScheduleRepository.ScheduleResult.Success(Unit) - val viewModel = DoneViewModel(scheduleRepository) + val viewModel = DoneViewModel(scheduleRepository, authRepository) viewModel.onIntent(DoneIntent.ToggleComplete("a")) viewModel.onIntent(DoneIntent.ToggleComplete("a")) coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("a", true, "2026-09-06") } coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("a", false, "2026-09-06") } } + + @Test + fun `로그아웃과 다른 계정으로 전환한 뒤 이전 완료 실패를 표시하지 않는다`() = + runTest { + todaySchedules.value = listOf(Schedule(id = "a", title = "이전 일정")) + val pending = CompletableDeferred>() + coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } coAnswers { pending.await() } + val vm = DoneViewModel(scheduleRepository, authRepository) + vm.onIntent(DoneIntent.ToggleComplete("a")) + currentUid.value = null + assertEquals(emptyList(), vm.uiState.value.schedules) + currentUid.value = "other" + todaySchedules.value = listOf(Schedule(id = "b", title = "새 일정")) + pending.complete(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError)) + assertNull(vm.uiState.value.error) + assertEquals( + listOf("b"), + vm.uiState.value.remaining + .map { it.id }, + ) + } + + @Test + fun `같은 계정 재로그인도 이전 요청을 분리하고 새 완료 입력을 허용한다`() = + runTest { + todaySchedules.value = listOf(Schedule(id = "a", title = "일정")) + val previous = CompletableDeferred>() + val next = CompletableDeferred>() + var calls = 0 + coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } coAnswers { + if (calls++ == 0) previous.await() else next.await() + } + val vm = DoneViewModel(scheduleRepository, authRepository) + vm.onIntent(DoneIntent.ToggleComplete("a")) + currentUid.value = null + currentUid.value = "owner" + vm.onIntent(DoneIntent.ToggleComplete("a")) + coVerify(exactly = 2) { scheduleRepository.markScheduleAsCompleted("a", true) } + previous.complete(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError)) + assertNull(vm.uiState.value.error) + assertEquals( + listOf("a"), + vm.uiState.value.completed + .map { it.id }, + ) + next.complete(ScheduleRepository.ScheduleResult.Success(Unit)) + } + + @Test + fun `완료 요청 취소도 현재 표시를 되돌리고 오류를 남기지 않는다`() = + runTest { + todaySchedules.value = listOf(Schedule(id = "a", title = "일정")) + coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } throws CancellationException("cancelled") + val vm = DoneViewModel(scheduleRepository, authRepository) + vm.onIntent(DoneIntent.ToggleComplete("a")) + assertEquals( + listOf("a"), + vm.uiState.value.remaining + .map { it.id }, + ) + assertNull(vm.uiState.value.error) + } + + @Test + fun `완료 구독 실패 후 재로그인하면 구독을 복구하고 로그아웃 재시도는 기다린다`() = + runTest { + var subscriptions = 0 + every { scheduleRepository.observeSchedulesForDate(any(), any()) } answers { + if (subscriptions++ == 0) { + flow { throw IllegalStateException("listener ended") } + } else { + flowOf(listOf(Schedule(id = "a", title = "복구"))) + } + } + val vm = DoneViewModel(scheduleRepository, authRepository) + currentUid.value = null + assertEquals(AppError.AuthError, vm.uiState.value.error) + vm.onIntent(DoneIntent.Retry) + verify(exactly = 1) { scheduleRepository.observeSchedulesForDate(any(), any()) } + currentUid.value = "owner" + assertEquals(2, subscriptions) + assertNull(vm.uiState.value.error) + assertEquals( + "a", + vm.uiState.value.remaining + .single() + .id, + ) + } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt index dd0c214..75b2a3f 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt @@ -226,6 +226,11 @@ class MainViewModelTest { viewModel.onIntent(MainIntent.ToggleComplete("s1")) assertEquals(AppError.NetworkError, viewModel.uiState.value.error) assertFalse(viewModel.uiState.value.canRetry) + assertFalse( + viewModel.uiState.value.todaySchedules + .single() + .completed, + ) viewModel.onIntent(MainIntent.ConsumeError) assertNull(viewModel.uiState.value.error) @@ -453,6 +458,52 @@ class MainViewModelTest { verify(exactly = 0) { scheduleRepository.observeSchedulesForDate(any(), any()) } } + @Test + fun `완료 확인 중 중복 토글을 막고 실패하면 원래 상태로 돌아간다`() = + runTest { + todaySchedules.value = listOf(soon) + val pending = CompletableDeferred>() + coEvery { scheduleRepository.markScheduleAsCompleted("s1", true) } coAnswers { pending.await() } + val vm = createViewModel() + vm.onIntent(MainIntent.ToggleComplete("s1")) + vm.onIntent(MainIntent.ToggleComplete("s1")) + assertTrue( + vm.uiState.value.todaySchedules + .single() + .completed, + ) + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("s1", true) } + pending.complete(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError)) + assertFalse( + vm.uiState.value.todaySchedules + .single() + .completed, + ) + } + + @Test + fun `완료 실패의 늦은 복구는 새 서버 스냅샷을 되돌리지 않는다`() = + runTest { + todaySchedules.value = listOf(soon) + val pending = CompletableDeferred>() + coEvery { scheduleRepository.markScheduleAsCompleted("s1", true) } coAnswers { pending.await() } + val vm = createViewModel() + vm.onIntent(MainIntent.ToggleComplete("s1")) + todaySchedules.value = listOf(soon.copy(title = "다른 기기 수정", completed = true)) + pending.complete(ScheduleRepository.ScheduleResult.Error(AppError.OnlineWriteError)) + assertTrue( + vm.uiState.value.todaySchedules + .single() + .completed, + ) + assertEquals( + "다른 기기 수정", + vm.uiState.value.todaySchedules + .single() + .title, + ) + } + @Test fun `삭제 실패는 목록 갱신 뒤에도 같은 삭제를 재시도한다`() = runTest { diff --git a/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt index 685b37d..95eaadb 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt @@ -1,13 +1,17 @@ package com.example.slowclock.ui.timeline import com.example.slowclock.data.model.Schedule +import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.ScheduleRepository +import com.example.slowclock.util.AppError import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -15,6 +19,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import java.util.Calendar @@ -22,10 +27,14 @@ import java.util.Calendar @OptIn(ExperimentalCoroutinesApi::class) class TimelineViewModelTest { private val scheduleRepository = mockk() + private val authRepository = mockk() + private val currentUid = MutableStateFlow("owner") @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) + every { authRepository.currentUid } answers { currentUid.value } + every { authRepository.observeCurrentUid() } returns currentUid every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns flowOf(listOf(Schedule(id = "a", title = "a"))) } @@ -40,7 +49,7 @@ class TimelineViewModelTest { val requested = slot() every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns flowOf(emptyList()) - val state = TimelineViewModel(scheduleRepository).uiState.value + val state = TimelineViewModel(scheduleRepository, authRepository).uiState.value val today = Calendar.getInstance() assertEquals(today.get(Calendar.DAY_OF_YEAR), requested.captured.get(Calendar.DAY_OF_YEAR)) @@ -51,7 +60,7 @@ class TimelineViewModelTest { @Test fun `날짜를 고르면 그 날짜로 다시 구독한다`() = runTest { - val viewModel = TimelineViewModel(scheduleRepository) + val viewModel = TimelineViewModel(scheduleRepository, authRepository) val requested = mutableListOf() every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns @@ -80,7 +89,7 @@ class TimelineViewModelTest { @Test fun `다음 날과 이전 날은 하루씩 옮긴다`() = runTest { - val viewModel = TimelineViewModel(scheduleRepository) + val viewModel = TimelineViewModel(scheduleRepository, authRepository) val start = viewModel.uiState.value.selectedDate val tomorrow = (start.clone() as Calendar).apply { add(Calendar.DAY_OF_MONTH, 1) } val requested = mutableListOf() @@ -94,4 +103,63 @@ class TimelineViewModelTest { assertEquals(start.timeInMillis, viewModel.uiState.value.selectedDate.timeInMillis) assertEquals(start.timeInMillis, requested.last().timeInMillis) } + + @Test + fun `로그아웃은 목록을 비우고 재로그인은 선택 날짜의 구독을 다시 시작한다`() = + runTest { + val requested = mutableListOf() + every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } answers { + flowOf(listOf(Schedule(id = currentUid.value.orEmpty(), title = "일정"))) + } + val vm = TimelineViewModel(scheduleRepository, authRepository) + vm.onIntent(TimelineIntent.NextDay) + val selected = vm.uiState.value.selectedDate.timeInMillis + currentUid.value = null + assertEquals(emptyList(), vm.uiState.value.schedules) + assertEquals(AppError.AuthError, vm.uiState.value.error) + vm.onIntent(TimelineIntent.Retry) + assertEquals(2, requested.size) + currentUid.value = "owner" + assertEquals(3, requested.size) + assertEquals(selected, requested.last().timeInMillis) + assertNull(vm.uiState.value.error) + assertEquals( + "owner", + vm.uiState.value.schedules + .single() + .id, + ) + currentUid.value = "other" + assertEquals(4, requested.size) + assertEquals( + "other", + vm.uiState.value.schedules + .single() + .id, + ) + } + + @Test + fun `실패로 끝난 구독도 같은 계정 재로그인 후 다시 시작한다`() = + runTest { + var subscriptions = 0 + every { scheduleRepository.observeSchedulesForDate(any(), any()) } answers { + if (subscriptions++ == 0) { + flow { throw IllegalStateException("listener ended") } + } else { + flowOf(listOf(Schedule(id = "new", title = "복구"))) + } + } + val vm = TimelineViewModel(scheduleRepository, authRepository) + currentUid.value = null + currentUid.value = "owner" + assertEquals(2, subscriptions) + assertNull(vm.uiState.value.error) + assertEquals( + "new", + vm.uiState.value.schedules + .single() + .id, + ) + } } diff --git a/firestore-tests/package.json b/firestore-tests/package.json index 4445102..1aac056 100644 --- a/firestore-tests/package.json +++ b/firestore-tests/package.json @@ -5,7 +5,7 @@ "description": "Firestore 보안 규칙 회귀 테스트. 에뮬레이터에 규칙을 올리고 앱이 실제로 하는 접근만 통과하는지 본다.", "type": "module", "scripts": { - "test": "firebase emulators:exec --only firestore --project slowclock-rules-test \"node --test rules.test.mjs\"" + "test": "firebase emulators:exec --only firestore --project slowclock-rules-test \"node --test *.test.mjs\"" }, "devDependencies": { "@firebase/rules-unit-testing": "5.0.2", diff --git a/firestore-tests/transactions.test.mjs b/firestore-tests/transactions.test.mjs new file mode 100644 index 0000000..9df12f1 --- /dev/null +++ b/firestore-tests/transactions.test.mjs @@ -0,0 +1,155 @@ +// 실제 transport를 끊는다. disableNetwork만으로는 transaction의 직접 RPC가 차단되지 않는다. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import net from "node:net"; +import { after, before, beforeEach, describe, it } from "node:test"; +import { initializeTestEnvironment, assertFails } from "@firebase/rules-unit-testing"; +import { collection, doc, getDoc, getDocs, runTransaction, setDoc, updateDoc, waitForPendingWrites } from "firebase/firestore"; + +let env; +let proxy; +let connected = true; +const sockets = new Set(); +const schedule = { userId: "owner", title: "약", sharedCode: "ABC123", completed: false }; + +before(async () => { + proxy = net.createServer((client) => { + if (!connected) { client.destroy(); return; } + const upstream = net.connect(8080, "127.0.0.1"); + sockets.add(client); sockets.add(upstream); + client.on("error", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); + client.on("close", () => { sockets.delete(client); upstream.destroy(); }); + upstream.on("close", () => { sockets.delete(upstream); client.destroy(); }); + client.pipe(upstream); upstream.pipe(client); + }); + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)); + env = await initializeTestEnvironment({ + projectId: "slowclock-transactions-test", + firestore: { host: "127.0.0.1", port: proxy.address().port, rules: await readFile(new URL("../firestore.rules", import.meta.url), "utf8") }, + }); +}); +after(async () => { + connected = true; + await env?.cleanup(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => proxy.close(resolve)); +}); +beforeEach(async () => { + connected = true; + await env.clearFirestore(); + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "users/owner"), { shareCode: "ABC123" }); + await setDoc(doc(ctx.firestore(), "shareCodeWatchers/ABC123/tokens/watcher"), { userId: "watcher" }); + }); +}); +const owner = () => env.authenticatedContext("owner").firestore(); +async function stored(path) { + let snapshot; + await env.withSecurityRulesDisabled(async (ctx) => { snapshot = await getDoc(doc(ctx.firestore(), path)); }); + return snapshot; +} +async function save(db, id, value = schedule, beforeCreate = async () => {}) { + return runTransaction(db, async (tx) => { + const ref = doc(db, "schedules", id); + const old = await tx.get(ref); + if (old.exists()) { + if (old.data().userId !== value.userId || old.data().title !== value.title) throw new Error("Existing schedule differs"); + return old.data(); + } + const user = await tx.get(doc(db, "users", value.userId)); + await beforeCreate(); + const code = user.data()?.shareCode ?? ""; + if (code) tx.set(doc(db, "shareCodes", code), { userId: value.userId }); + tx.set(ref, { ...value, sharedCode: code }); + return value; + }); +} + +describe("온라인 일정 transaction", () => { + it("누락 등록부 복구와 생성이 원자적으로 성공하고 등록부 읽기는 닫혀 있다", async () => { + const db = owner(); + await save(db, "s1"); + assert.equal((await stored("shareCodes/ABC123")).data().userId, "owner"); + assert.equal((await getDoc(doc(db, "schedules/s1"))).data().sharedCode, "ABC123"); + await assertFails(getDoc(doc(db, "shareCodes/ABC123"))); + await assertFails(getDocs(collection(db, "shareCodes"))); + }); + + it("등록부 충돌은 일정 생성과 기존 문서 변경을 모두 거절한다", async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "shareCodes/ABC123"), { userId: "other" }); + }); + await assertFails(save(owner(), "s1")); + assert.equal((await stored("schedules/s1")).exists(), false); + assert.equal((await stored("shareCodes/ABC123")).data().userId, "other"); + }); + + it("동일 ID의 뒤늦은 첫 commit은 재시도되어 이미 저장된 내용을 덮지 않는다", async () => { + const db = owner(); + let resume; + let ready; + const started = new Promise((resolve) => { ready = resolve; }); + const gate = new Promise((resolve) => { resume = resolve; }); + const first = save(db, "stable", schedule, async () => { ready(); await gate; }); + await started; + await save(db, "stable"); + await updateDoc(doc(db, "schedules/stable"), { completed: true }); + resume(); + const returned = await first; + assert.equal(returned.completed, true); + assert.equal((await stored("schedules/stable")).data().completed, true); + await env.withSecurityRulesDisabled(async (ctx) => { + assert.equal((await getDocs(collection(ctx.firestore(), "schedules"))).size, 1); + }); + await assert.rejects(save(db, "stable", { ...schedule, title: "다른 내용" }), /differs/); + assert.equal((await stored("schedules/stable")).data().title, "약"); + }); + + it("연결이 실제로 끊긴 생성 편집 삭제 완료는 복구 뒤에도 큐에서 재생되지 않는다", async () => { + const db = owner(); + await save(db, "existing"); + connected = false; + for (const socket of sockets) socket.destroy(); + const ref = doc(db, "schedules/existing"); + const results = await Promise.allSettled([ + save(db, "offline-new"), + runTransaction(db, async (tx) => { await tx.get(ref); tx.update(ref, { title: "offline edit" }); }), + runTransaction(db, async (tx) => { await tx.get(ref); tx.delete(ref); }), + runTransaction(db, async (tx) => { await tx.get(ref); tx.update(ref, { completed: true }); }), + ]); + assert.ok(results.every((result) => result.status === "rejected")); + // TCP reset can surface as CANCELLED on Linux gRPC. Both are transport failures; + // permission/validation errors must still fail this assertion. + for (const result of results) assert.ok(["unavailable", "cancelled"].includes(result.reason.code), result.reason.code); + connected = true; + await waitForPendingWrites(db); + assert.equal((await stored("schedules/offline-new")).exists(), false); + assert.deepEqual((await stored("schedules/existing")).data(), schedule); + await save(db, "offline-new"); + assert.equal((await stored("schedules/offline-new")).exists(), true); + }); + + it("감시자 완료 transaction은 등록부를 쓰지 않고 완료 필드만 바꾼다", async () => { + await save(owner(), "s1"); + const db = env.authenticatedContext("watcher").firestore(); + await runTransaction(db, async (tx) => { + const ref = doc(db, "schedules/s1"); + await tx.get(ref); + tx.update(ref, { completed: true }); + }); + assert.equal((await stored("schedules/s1")).data().completed, true); + await assertFails(runTransaction(db, async (tx) => { + const ref = doc(db, "schedules/s1"); await tx.get(ref); tx.update(ref, { title: "변경" }); + })); + }); + + it("미존재 get은 로그인 요청만 허용하고 타인 기존 일정과 목록은 계속 닫는다", async () => { + const outsider = env.authenticatedContext("outsider").firestore(); + assert.equal((await getDoc(doc(outsider, "schedules/missing"))).exists(), false); + await assertFails(getDoc(doc(env.unauthenticatedContext().firestore(), "schedules/missing"))); + await save(owner(), "private"); + await assertFails(getDoc(doc(outsider, "schedules/private"))); + await assertFails(getDocs(collection(outsider, "schedules"))); + }); +}); diff --git a/firestore.rules b/firestore.rules index 41105c8..9c74790 100644 --- a/firestore.rules +++ b/firestore.rules @@ -24,7 +24,7 @@ service cloud.firestore { return !('sharedCode' in request.resource.data) || request.resource.data.sharedCode == '' || (request.resource.data.sharedCode is string - && get(/databases/$(database)/documents/shareCodes/$(request.resource.data.sharedCode)).data.userId == request.auth.uid); + && getAfter(/databases/$(database)/documents/shareCodes/$(request.resource.data.sharedCode)).data.userId == request.auth.uid); } // 사용자 문서. 이메일과 FCM 토큰이 함께 있어 본인만 읽고 쓴다. @@ -71,6 +71,8 @@ service cloud.firestore { // 일정 match /schedules/{scheduleId} { + // 고정 제출 ID의 첫 transaction은 아직 없는 문서도 확인해야 한다. 목록 권한은 열지 않는다. + allow get: if isSignedIn() && !exists(/databases/$(database)/documents/schedules/$(scheduleId)); // 생성할 때 소유자를 확인하고, 기존 일정은 저장된 소유자를 기준으로 확인한다. // 일정을 고칠 때도 소유자는 그대로 유지한다. allow read, delete: if isSelf(resource.data.userId);