From ea602df94db9d7230a17702c35655b645f47a808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 17:30:20 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=98=A8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=93=B0=EA=B8=B0=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20ID=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #204 일정 변경을 transaction으로 처리하고 서버 확인 후 알람을 처리한다. 신규 제출 ID와 입력을 SavedStateHandle에 보존하고 완료 실패의 임시 표시를 복구한다. 검증: 실제 TCP 단절 emulator 포함 62개, Kotlin data44/add26/main54, 영향 ktlint 및 app assembleDebug/lintDebug 통과. Sources: https://firebase.google.com/docs/firestore/manage-data/transactions https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-play-services/kotlinx.coroutines.tasks/await.html --- .../com/example/slowclock/util/ErrorType.kt | 4 + .../remote/repository/ScheduleRepository.kt | 280 ++++++++---------- .../ScheduleRepositoryAddScheduleTest.kt | 27 +- .../ScheduleRepositoryTransactionTest.kt | 185 ++++++++++++ .../ScheduleRepositoryUpdateTest.kt | 23 +- .../repository/ScheduleTransactionFixture.kt | 63 ++++ .../ui/common/components/ErrorCard.kt | 7 +- docs/firestore.md | 14 +- .../ui/addschedule/AddScheduleViewModel.kt | 24 +- .../ui/addschedule/ScheduleDraftState.kt | 41 +++ .../ui/addschedule/AddScheduleDraftTest.kt | 95 ++++++ .../addschedule/AddScheduleViewModelTest.kt | 5 +- .../example/slowclock/ui/done/DoneContract.kt | 4 + .../slowclock/ui/done/DoneViewModel.kt | 48 ++- .../example/slowclock/ui/main/MainContract.kt | 6 + .../slowclock/ui/main/MainViewModel.kt | 97 +++--- .../slowclock/ui/done/DoneViewModelTest.kt | 26 +- .../slowclock/ui/main/MainViewModelTest.kt | 51 ++++ firestore-tests/package.json | 2 +- firestore-tests/rules.test.mjs | 137 ++++++++- firestore-tests/transactions.test.mjs | 153 ++++++++++ firestore.rules | 25 +- 22 files changed, 1066 insertions(+), 251 deletions(-) create mode 100644 core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryTransactionTest.kt create mode 100644 core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleTransactionFixture.kt create mode 100644 feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/ScheduleDraftState.kt create mode 100644 feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleDraftTest.kt create mode 100644 firestore-tests/transactions.test.mjs 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 e81fcda3..0f843ebd 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 eaf93255..b6f8ad7d 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()) } @@ -184,194 +190,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: 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 { - schedulesCollection - .document(schedule.id) - .update(changes) - .await() - - Log.d("ScheduleRepo", "일정 수정 성공: ${schedule.id}") - ScheduleResult.Success(Unit) + 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()) { @@ -400,6 +372,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()) @@ -541,6 +515,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 @@ -560,6 +536,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 55a4db39..1760660b 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/ScheduleRepositoryTransactionTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryTransactionTest.kt new file mode 100644 index 00000000..656bace0 --- /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 663d095b..356ce48a 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,20 +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() - 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 @@ -85,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 @@ -111,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 00000000..c905b98a --- /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 9002df1c..ba7e1ecf 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 @@ -63,13 +63,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 }, @@ -83,12 +83,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 c9ede3e0..9e885ee4 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,18 @@ Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 2. 공유 일정은 「공유로 표시됐는가」 가 아니라 「그 코드의 감시자로 등록한 사람인가」 로 연다. 등록 문서(`shareCodeWatchers/{code}/tokens/{uid}`)가 그 관계의 증거다. 이 문서는 읽기 권한의 근거이기도 해서, FCM 토큰을 못 받아도 등록 자체는 한다. 3. 읽기보다 쓰기를 더 좁게 본다. 남이 완료 표시를 뒤집을 수 있으면 어르신이 약을 이미 먹은 줄로 안다. 이 앱에서는 그쪽이 더 아픈 피해다. +## 일정 쓰기와 재시도 + +일정 생성·편집·삭제·완료는 온라인 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 로 동작해 규칙을 거치지 않는다. 규칙만 읽으면 이 경로를 놓친다. 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 25ac7f92..f050d472 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 @@ -10,9 +11,12 @@ import com.example.slowclock.util.AppError import com.google.firebase.Timestamp import dagger.hilt.android.lifecycle.HiltViewModel 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 +28,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) { @@ -85,6 +92,7 @@ class AddScheduleViewModel dispatch(AddScheduleReducerEvent.SavedConsumed) } } + if (!currentState.isEditMode) savedStateHandle.saveDraft(currentState) } override fun reduce( @@ -208,7 +216,9 @@ class AddScheduleViewModel } is ScheduleRepository.ScheduleResult.Error -> { - dispatch(AddScheduleReducerEvent.Failed(result.error, canRetry = true)) + dispatch( + AddScheduleReducerEvent.Failed(result.error, canRetry = result.error !is AppError.ScheduleConflictError), + ) } } } @@ -228,21 +238,22 @@ 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 로 걸면 모든 새 일정이 같은 요청 코드를 쓴다. - runCatching { alarmScheduler.schedule(schedule.copy(id = result.data)) } + runCatching { alarmScheduler.schedule(result.data) } .onFailure { Log.e(TAG, "알람 예약 실패", it) } dispatch(AddScheduleReducerEvent.Saved) } 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)) } } } @@ -279,7 +290,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(), @@ -298,6 +309,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 00000000..b231ce32 --- /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 00000000..39843dcd --- /dev/null +++ b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleDraftTest.kt @@ -0,0 +1,95 @@ +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.mockk +import io.mockk.verify +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.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()) } + } +} 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 38794d71..21f22bce 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 @@ -84,7 +84,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() @@ -141,7 +142,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 af394dff..39f618eb 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 b1d88522..01f12bf1 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 @@ -6,6 +6,7 @@ import com.example.slowclock.data.remote.repository.ScheduleRepository 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.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch @@ -20,6 +21,8 @@ class DoneViewModel private val scheduleRepository: ScheduleRepository, ) : MviViewModel(DoneUiState()) { private var scheduleJob: Job? = null + private var scheduleRevision = 0L + private val pendingCompletions = mutableSetOf() /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ private var subscribedDay: String = "" @@ -72,6 +75,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 +95,7 @@ class DoneViewModel private fun observeTodaySchedules() { scheduleJob?.cancel() + scheduleRevision++ subscribedDay = todayKey() scheduleJob = viewModelScope.launch { @@ -88,7 +105,10 @@ class DoneViewModel .catch { e -> Log.e(TAG, "일정 구독 실패", e) dispatch(DoneReducerEvent.Failed(e.toAppError())) - }.collect { dispatch(DoneReducerEvent.Loaded(it)) } + }.collect { + scheduleRevision++ + dispatch(DoneReducerEvent.Loaded(it)) + } } } @@ -111,17 +131,25 @@ class DoneViewModel private fun toggleComplete(scheduleId: String) { val schedule = currentState.schedules.find { it.id == scheduleId } ?: return + if (!pendingCompletions.add(scheduleId)) return + val revision = scheduleRevision dispatch(DoneReducerEvent.Toggled(scheduleId)) + + fun restoreIfCurrent() { + if (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) { + restoreIfCurrent() + dispatch(DoneReducerEvent.Failed(result.error)) + } + } catch (e: CancellationException) { + restoreIfCurrent() + throw e + } finally { + pendingCompletions.remove(scheduleId) } } } 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 ad14311d..c2a8f1c8 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 @@ -101,6 +101,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 b21eb0cc..7b306964 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,7 +28,7 @@ import javax.inject.Inject /** * 메인 화면. 오늘 일정은 Firestore 리스너로 받는다. 완료 토글·삭제는 낙관적으로 먼저 반영하고, - * 실패하면 리스너가 서버 상태로 되돌린다. 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 + * 실패하면 최신 서버 목록을 덮지 않는 범위에서 완료 표시를 되돌린다. 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 * 다시 구독한다. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -42,6 +43,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() /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ private var subscribedDay: String = "" @@ -165,6 +170,20 @@ class MainViewModel state.copy(isLoading = false, error = event.error, canRetry = event.canRetry) } + 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 }, @@ -262,6 +281,9 @@ class MainViewModel alarmSyncJob?.cancel() alarmSyncJob = null alarmSyncedFor = null + completionUserGeneration++ + ownRevision++ + sharedRevision++ dispatch(MainReducerEvent.UserResolved(uid.orEmpty())) if (uid != null) { observeTodaySchedules() @@ -313,6 +335,7 @@ class MainViewModel Log.e(TAG, "일정 구독 실패", e) dispatch(MainReducerEvent.LoadFailed(e.toAppError(), canRetry = true)) }.collect { schedules -> + ownRevision++ dispatch(MainReducerEvent.SchedulesLoaded(schedules, System.currentTimeMillis())) } } @@ -349,6 +372,7 @@ class MainViewModel } } }.collect { reminders -> + sharedRevision++ val today = Calendar.getInstance() val todayReminders = reminders.filter { it.startTime.toDate().isSameDay(today) } dispatch(MainReducerEvent.SharedRemindersLoaded(todayReminders)) @@ -376,18 +400,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) } } } @@ -411,27 +462,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/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt index fccb726a..bbea41f5 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 @@ -7,6 +7,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -52,7 +53,7 @@ class DoneViewModelTest { } @Test - fun `토글은 먼저 반영하고 실패하면 오류를 둔다`() = + fun `토글 실패는 완료 표시를 되돌리고 오류를 둔다`() = runTest { todaySchedules.value = listOf(Schedule(id = "a", title = "a")) coEvery { scheduleRepository.markScheduleAsCompleted("a", true) } returns @@ -62,7 +63,7 @@ class DoneViewModelTest { viewModel.onIntent(DoneIntent.ToggleComplete("a")) assertEquals( - listOf("a"), + emptyList(), viewModel.uiState.value.completed .map { it.id }, ) @@ -72,4 +73,25 @@ class DoneViewModelTest { viewModel.onIntent(DoneIntent.ConsumeError) 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) + 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, + ) + } } 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 427cf7ab..299d66d2 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 @@ -210,6 +210,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) @@ -432,4 +437,50 @@ 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, + ) + } } diff --git a/firestore-tests/package.json b/firestore-tests/package.json index 4445102b..1aac0566 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/rules.test.mjs b/firestore-tests/rules.test.mjs index 3dfed65a..0bcba638 100644 --- a/firestore-tests/rules.test.mjs +++ b/firestore-tests/rules.test.mjs @@ -25,6 +25,7 @@ import { setDoc, updateDoc, where, + writeBatch, } from "firebase/firestore"; const OWNER = "uid-owner"; @@ -180,12 +181,13 @@ describe("publicProfiles", () => { }); it("계정을 삭제할 때 본인 공개 프로필도 지운다", async () => { - // UserRepository.deleteUserDocument 와 같은 순서다. 사용자 문서가 없어도 본인 확인은 - // 인증 uid 로 해야 하며, 삭제에는 request.resource 의 새 필드가 없다. + // UserRepository.deleteUserDocument 와 같은 원자 batch다. const db = owner(); - await assertSucceeds(deleteDoc(doc(db, "shareCodes", SHARE_CODE))); - await assertSucceeds(deleteDoc(doc(db, "users", OWNER))); - await assertSucceeds(deleteDoc(doc(db, "publicProfiles", OWNER))); + const batch = writeBatch(db); + batch.delete(doc(db, "shareCodes", SHARE_CODE)); + batch.delete(doc(db, "users", OWNER)); + batch.delete(doc(db, "publicProfiles", OWNER)); + await assertSucceeds(batch.commit()); assert.equal((await getDoc(doc(db, "publicProfiles", OWNER))).exists(), false); await assertSucceeds(deleteDoc(doc(db, "publicProfiles", OWNER))); }); @@ -246,8 +248,131 @@ describe("shareCodes", () => { }); it("본인 코드만 반납한다", async () => { - await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", SHARE_CODE))); await assertFails(deleteDoc(doc(other(), "shareCodes", SHARE_CODE))); + await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", SHARE_CODE))); + }); +}); + +describe("공유 등록부 복구와 삭제 재시도", () => { + it("기존 소유자는 동일한 값으로만 등록부를 확인한다", async () => { + const ref = doc(owner(), "shareCodes", SHARE_CODE); + await assertSucceeds(setDoc(ref, { userId: OWNER })); + await assertFails(setDoc(ref, { userId: OTHER })); + await assertFails(setDoc(ref, { userId: OWNER, extra: true })); + await assertFails(getDoc(ref)); + await assertFails(getDocs(collection(owner(), "shareCodes"))); + }); + + it("등록부가 없는 이전 계정은 본인 코드를 복구한 뒤 공유 일정을 쓴다", async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + await deleteDoc(doc(context.firestore(), "shareCodes", SHARE_CODE)); + }); + const db = owner(); + await assertFails(updateDoc(doc(db, "schedules", "shared-1"), { title: "수정" })); + await assertSucceeds(setDoc(doc(db, "shareCodes", SHARE_CODE), { userId: OWNER })); + await assertSucceeds(updateDoc(doc(db, "schedules", "shared-1"), { title: "수정" })); + }); + + it("없는 등록부 삭제는 로그인한 재시도에만 허용한다", async () => { + await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", "MISSING"))); + await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", "MISSING"))); + await assertFails(deleteDoc(doc(anonymous(), "shareCodes", "MISSING"))); + }); + + it("등록부가 먼저 삭제된 계정도 나머지 문서를 한 번에 지우고 재시도한다", async () => { + const db = owner(); + await assertSucceeds(deleteDoc(doc(db, "shareCodes", SHARE_CODE))); + for (let attempt = 0; attempt < 2; attempt += 1) { + const batch = writeBatch(db); + batch.delete(doc(db, "shareCodes", SHARE_CODE)); + batch.delete(doc(db, "users", OWNER)); + batch.delete(doc(db, "publicProfiles", OWNER)); + await assertSucceeds(batch.commit()); + } + assert.equal((await getDoc(doc(db, "users", OWNER))).exists(), false); + assert.equal((await getDoc(doc(db, "publicProfiles", OWNER))).exists(), false); + }); + + it("등록부 삭제가 거절되면 사용자와 공개 프로필도 보존한다", async () => { + const db = owner(); + const batch = writeBatch(db); + batch.delete(doc(db, "shareCodes", "ZZZ999")); + batch.delete(doc(db, "users", OWNER)); + batch.delete(doc(db, "publicProfiles", OWNER)); + await assertFails(batch.commit()); + assert.equal((await getDoc(doc(db, "users", OWNER))).exists(), true); + assert.equal((await getDoc(doc(db, "publicProfiles", OWNER))).exists(), true); + await testEnv.withSecurityRulesDisabled(async (context) => { + assert.equal((await getDoc(doc(context.firestore(), "shareCodes", "ZZZ999"))).data().userId, OTHER); + }); + }); +}); + +describe("공유 일정의 코드 소유권", () => { + for (const sharedCode of ["", undefined]) { + it(`비공유 일정(${String(sharedCode)})은 등록부 없이 저장한다`, async () => { + const data = { userId: STRANGER, title: "비공유" }; + if (sharedCode !== undefined) data.sharedCode = sharedCode; + const ref = doc(stranger(), "schedules", "private-new"); + await assertSucceeds(setDoc(ref, data)); + await assertSucceeds(updateDoc(ref, { title: "수정" })); + }); + } + + it("본인 등록부의 코드로 생성하고 비공유 일정을 채운다", async () => { + const db = owner(); + await assertSucceeds(setDoc(doc(db, "schedules", "shared-new"), { + userId: OWNER, title: "공유", sharedCode: SHARE_CODE, + })); + await assertSucceeds(updateDoc(doc(db, "schedules", "own-1"), { sharedCode: SHARE_CODE })); + await assertSucceeds(updateDoc(doc(db, "schedules", "shared-1"), { title: "수정" })); + }); + + for (const sharedCode of ["ZZZ999", "MISSING", null, 123]) { + it(`소유권 없는 코드(${String(sharedCode)})로 생성하거나 수정하지 못한다`, async () => { + const db = owner(); + await assertFails(setDoc(doc(db, "schedules", "invalid-new"), { + userId: OWNER, title: "공유", sharedCode, + })); + await assertFails(updateDoc(doc(db, "schedules", "own-1"), { sharedCode })); + }); + } + + it("같은 코드의 일정 batch는 등록부 조회 한도 안에서 채운다", async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const batch = writeBatch(context.firestore()); + for (let i = 0; i < 30; i += 1) { + batch.set(doc(context.firestore(), "schedules", `backfill-${i}`), { + userId: OWNER, title: "비공유 일정", sharedCode: "", + }); + } + await batch.commit(); + }); + const db = owner(); + const batch = writeBatch(db); + for (let i = 0; i < 30; i += 1) { + batch.update(doc(db, "schedules", `backfill-${i}`), { sharedCode: SHARE_CODE }); + } + await assertSucceeds(batch.commit()); + assert.equal((await getDoc(doc(db, "schedules", "backfill-29"))).data().sharedCode, SHARE_CODE); + }); + + it("사용자 문서의 코드만 바꿔서는 일정 공유 소유권을 얻지 못한다", async () => { + const db = owner(); + await assertSucceeds(updateDoc(doc(db, "users", OWNER), { shareCode: "ZZZ999" })); + await assertFails(setDoc(doc(db, "schedules", "invalid-new"), { + userId: OWNER, title: "공유", sharedCode: "ZZZ999", + })); + await assertFails(updateDoc(doc(db, "schedules", "own-1"), { sharedCode: "ZZZ999" })); + }); + + it("등록부가 없어도 기존 감시자는 완료만 수정하고 소유자는 삭제할 수 있다", async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + await deleteDoc(doc(context.firestore(), "shareCodes", SHARE_CODE)); + }); + await assertSucceeds(updateDoc(doc(other(), "schedules", "shared-1"), { completed: true })); + await assertFails(updateDoc(doc(other(), "schedules", "shared-1"), { title: "수정" })); + await assertSucceeds(deleteDoc(doc(owner(), "schedules", "shared-1"))); }); }); diff --git a/firestore-tests/transactions.test.mjs b/firestore-tests/transactions.test.mjs new file mode 100644 index 00000000..94e053c2 --- /dev/null +++ b/firestore-tests/transactions.test.mjs @@ -0,0 +1,153 @@ +// 실제 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")); + for (const result of results) assert.equal(result.reason.code, "unavailable"); + 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 7ed1cdc7..9c747907 100644 --- a/firestore.rules +++ b/firestore.rules @@ -19,6 +19,14 @@ service cloud.firestore { ); } + // 비공유 일정은 그대로 허용하고, 공유할 때는 등록부의 소유자를 확인한다. + function ownsRequestedShareCode() { + return !('sharedCode' in request.resource.data) + || request.resource.data.sharedCode == '' + || (request.resource.data.sharedCode is string + && getAfter(/databases/$(database)/documents/shareCodes/$(request.resource.data.sharedCode)).data.userId == request.auth.uid); + } + // 사용자 문서. 이메일과 FCM 토큰이 함께 있어 본인만 읽고 쓴다. // 다른 사람에게 보여야 하는 이름은 publicProfiles 에 따로 둔다(#93). match /users/{userId} { @@ -51,18 +59,27 @@ service cloud.firestore { allow create: if isSignedIn() && request.resource.data.keys().hasOnly(['userId']) && request.resource.data.userId == request.auth.uid; - allow update: if false; - allow delete: if isSignedIn() && resource.data.userId == request.auth.uid; + // 기존 코드도 조회 권한을 열지 않고 같은 값으로 등록·확인할 수 있다. + allow update: if isSignedIn() + && resource.data.userId == request.auth.uid + && request.resource.data == resource.data; + // 이전 부분 삭제나 재시도에서 이미 없는 등록부는 성공으로 처리한다. + allow delete: if isSignedIn() + && (!exists(/databases/$(database)/documents/shareCodes/$(shareCode)) + || resource.data.userId == request.auth.uid); } // 일정 match /schedules/{scheduleId} { + // 고정 제출 ID의 첫 transaction은 아직 없는 문서도 확인해야 한다. 목록 권한은 열지 않는다. + allow get: if isSignedIn() && !exists(/databases/$(database)/documents/schedules/$(scheduleId)); // 생성할 때 소유자를 확인하고, 기존 일정은 저장된 소유자를 기준으로 확인한다. // 일정을 고칠 때도 소유자는 그대로 유지한다. allow read, delete: if isSelf(resource.data.userId); - allow create: if isSelf(request.resource.data.userId); + allow create: if isSelf(request.resource.data.userId) && ownsRequestedShareCode(); allow update: if isSelf(resource.data.userId) - && request.resource.data.userId == resource.data.userId; + && request.resource.data.userId == resource.data.userId + && ownsRequestedShareCode(); // 공유 일정은 그 코드의 감시자로 등록한 사람만 읽는다. allow read: if resource.data.sharedCode is string && From a3a3d57e4291a69a5fb4c5f2f1336f5971977783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 17:38:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=EC=97=B0=EA=B2=B0=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=EC=9D=98=20gRPC=20=EC=A2=85=EB=A3=8C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B0=A8=EC=9D=B4=EB=A5=BC=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux CI가 TCP reset 뒤 반환한 cancelled와 로컬 unavailable만 허용한다. 네 쓰기의 실패와 연결 복구 후 대기열 재생 없음, 기존 문서 보존 단언은 유지했다. Emulator 62개 통과. Source: https://grpc.io/docs/guides/status-codes/ --- firestore-tests/transactions.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/firestore-tests/transactions.test.mjs b/firestore-tests/transactions.test.mjs index 94e053c2..9df12f18 100644 --- a/firestore-tests/transactions.test.mjs +++ b/firestore-tests/transactions.test.mjs @@ -119,7 +119,9 @@ describe("온라인 일정 transaction", () => { runTransaction(db, async (tx) => { await tx.get(ref); tx.update(ref, { completed: true }); }), ]); assert.ok(results.every((result) => result.status === "rejected")); - for (const result of results) assert.equal(result.reason.code, "unavailable"); + // 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); From f7655f3c3a647278439393caaa86e4e426000104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 17:45:08 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=99=84=EB=A3=8C=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=ED=91=9C=EC=8B=9C=EC=99=80=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=9D=98=20=EC=84=9C=EB=B2=84=20=ED=99=95=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=EC=9D=84=20=EA=B5=AC=EB=B6=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/slowclock/ui/main/MainViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 4cfb62b9..5968ea48 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 @@ -28,9 +28,9 @@ import java.util.UUID import javax.inject.Inject /** - * 메인 화면. 오늘 일정은 Firestore 리스너로 받는다. 완료 토글·삭제는 낙관적으로 먼저 반영하고, - * 실패하면 최신 서버 목록을 덮지 않는 범위에서 완료 표시를 되돌린다. 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 - * 다시 구독한다. + * 메인 화면. 오늘 일정은 Firestore 리스너로 받는다. 완료는 임시 표시하고 삭제는 서버 확인 후 반영한다. + * 완료 실패는 최신 서버 목록을 덮지 않는 범위에서 표시를 되돌린다. + * 공유 일정은 기기에 저장된 공유 코드가 바뀔 때마다 다시 구독한다. */ @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel From a0259cdac4c784ba952489028c423fa03c68cd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 17:50:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EA=B3=B5=EC=9C=A0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EB=B6=80=20=EC=A0=90=EA=B2=80=EA=B3=BC=20=EC=98=A8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=93=B0=EA=B8=B0=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A5=BC=20=EB=AA=85=EC=8B=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 앱 계약이 구버전 오프라인 큐를 제거하지 않는 점과 운영 미배포·inventory 미확인 상태를 기록한다. 등록부 충돌 정리, 규칙 적용, 새 앱 검증의 순서를 분명히 했다. Markdown 링크 13개 문서 검사와 diff check 통과. Sources: https://firebase.google.com/docs/firestore/manage-data/transactions https://firebase.google.com/docs/firestore/security/rules-conditions --- docs/firestore.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/firestore.md b/docs/firestore.md index 64937860..83beb349 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -35,6 +35,8 @@ Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 ## 일정 쓰기와 재시도 +아래 계약은 온라인 쓰기 구현을 포함한 새 앱 버전에 적용된다. 구버전 앱에 남은 오프라인 쓰기 대기열을 제거하거나, 이미 대기 중인 요청을 같은 ID 재시도로 바꾸지는 않는다. 구버전 잔존 여부와 재연결 후 기존 요청의 처리를 출시 검증에 포함한다. + 일정 생성·편집·삭제·완료는 온라인 transaction으로 처리한다. 서버가 결과를 확인한 뒤에 저장 성공을 표시하고 알람 후처리를 한다. 연결이 끊기면 연결 필요 안내와 재시도를 제공하며, 입력은 그대로 유지한다. 조회용 오프라인 캐시는 유지하지만 이 네 가지 쓰기를 오프라인 대기열에 넣지는 않는다. transaction callback에는 UI나 알람 처리를 넣지 않는다. 새 일정 ID는 제출 전에 만들어 `SavedStateHandle`에 입력과 함께 보존한다. 같은 ID의 문서가 이미 있으면 소유자와 편집 필드를 비교하며, 같은 요청만 서버의 최신 문서를 반환한다. 다른 내용은 덮어쓰거나 성공으로 처리하지 않고 목록에서 확인하도록 안내한다. 코루틴 취소가 이미 전송한 commit을 철회하지는 않으므로, 재시도에서도 같은 ID를 쓰는 것이 필요하다. @@ -65,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