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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ class ScheduleRepository
if (userShareCode.isBlank()) {
Log.w("ScheduleRepo", "공유 코드가 비어 있어 이 일정은 가족에게 보이지 않는다")
}
// 앱 시작의 비동기 복구보다 저장이 먼저 실행돼도 등록부를 확인한 뒤 쓴다.
firestore.ensureShareCodeOwner(uid, userShareCode)
return userShareCode
}

Expand Down Expand Up @@ -218,6 +220,8 @@ class ScheduleRepository
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 =
Expand Down Expand Up @@ -319,13 +323,16 @@ class ScheduleRepository
)

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()))
} catch (e: FirebaseFirestoreException) {
Log.e("ScheduleRepo", "일정 수정 실패: ${e.code}", e)
val error =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.slowclock.data.remote.repository

import com.example.slowclock.data.FirestoreCollections
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.FirebaseFirestoreException
import kotlinx.coroutines.tasks.await

/** 기존 코드의 등록부를 보완한다. 같은 소유자의 동일값 쓰기만 규칙이 허용한다. */
internal suspend fun FirebaseFirestore.ensureShareCodeOwner(
userId: String,
shareCode: String,
) {
if (shareCode.isBlank()) return
try {
collection(FirestoreCollections.SHARE_CODES)
.document(shareCode)
.set(mapOf("userId" to userId))
.await()
} catch (e: FirebaseFirestoreException) {
if (e.code == FirebaseFirestoreException.Code.PERMISSION_DENIED) {
throw ShareCodeOwnershipException(e)
}
throw e
}
}

internal class ShareCodeOwnershipException(
cause: Throwable,
) : Exception(
"공유 코드의 소유권을 확인하지 못했습니다. 기존 코드는 유지됩니다. 관리자에게 문의해 주세요.",
cause,
)
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class UserRepository
/**
* 아직 임자가 없는 여섯 자리 코드를 하나 만들어 [uid] 앞으로 등록한다.
*
* 등록부는 코드 자체가 문서 이름이라, 이미 있는 코드에 대한 만들기는 규칙이 거절한다.
* 등록부는 코드 자체가 문서 이름이라, 다른 소유자의 코드에 대한 쓰기는 규칙이 거절한다.
* 확인과 저장이 한 번으로 합쳐지므로 그 사이에 같은 코드를 두 사람이 가져가는 틈이 없다.
* 질의로 확인하던 앞의 방식은 컬렉션을 훑을 권한을 함께 열어야 했다(#174).
*/
Expand Down Expand Up @@ -112,11 +112,15 @@ class UserRepository
.await()
.getString("shareCode")
.orEmpty()
if (shareCode.isNotBlank()) {
shareCodesCollection.document(shareCode).delete().await()
}
usersCollection.document(userId).delete().await()
publicProfilesCollection.document(userId).delete().await()
// 셋 중 하나만 지워지면 다음 시도에서 코드를 잃거나 삭제가 막힐 수 있다.
firestore
.batch()
.apply {
if (shareCode.isNotBlank()) delete(shareCodesCollection.document(shareCode))
delete(usersCollection.document(userId))
delete(publicProfilesCollection.document(userId))
}.commit()
.await()
true
} catch (e: Exception) {
Log.e(TAG, "사용자 문서 삭제 실패", e)
Expand Down Expand Up @@ -148,6 +152,9 @@ class UserRepository
usersCollection.document(uid).set(newUser).await()
savePublicProfile(newUser.id, newUser.name)
} else {
// 등록부 도입 전 발급된 코드도 같은 소유자로 등록한다. 타인 코드와 충돌하면
// 기존 코드·문서를 그대로 두고 실패한다. 자동 회전은 기존 일정을 분리한다.
firestore.ensureShareCodeOwner(uid, existing.shareCode)
val updates = mutableMapOf<String, Any>()
if (existing.name != name && name.isNotBlank()) updates["name"] = name
if (existing.email != email && email.isNotBlank()) updates["email"] = email
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ class ScheduleRepositoryAddScheduleTest {
every { firestore.collection("schedules") } returns schedulesCollection
every { firestore.collection("users") } returns usersCollection
every { firestore.batch() } returns batch
val registry = mockk<CollectionReference>()
val codeRef = mockk<DocumentReference>()
every { firestore.collection("shareCodes") } returns registry
every { registry.document(shareCode) } returns codeRef
every { codeRef.set(mapOf("userId" to uid)) } returns Tasks.forResult(null)

return ScheduleRepository(auth, firestore) to batch
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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.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.FirebaseFirestoreException
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class ScheduleRepositoryShareCodeTest {
private val uid = "owner"
private val code = "ABC123"
private val auth = mockk<FirebaseAuth>()
private val firestore = mockk<FirebaseFirestore>()
private val users = mockk<CollectionReference>()
private val schedules = mockk<CollectionReference>()
private val registry = mockk<CollectionReference>()
private val userRef = mockk<DocumentReference>()
private val scheduleRef = mockk<DocumentReference>()
private val codeRef = mockk<DocumentReference>()
private val snapshot = mockk<DocumentSnapshot>()
private val repository: ScheduleRepository
private val schedule = Schedule(id = "schedule", title = "혈압약", userId = uid, sharedCode = code)

init {
val user = mockk<FirebaseUser>()
every { user.uid } returns uid
every { auth.currentUser } returns user
every { firestore.collection("users") } returns users
every { firestore.collection("schedules") } returns schedules
every { firestore.collection("shareCodes") } returns registry
every { users.document(uid) } returns userRef
every { schedules.document() } returns scheduleRef
every { schedules.document(schedule.id) } returns scheduleRef
every { scheduleRef.id } returns schedule.id
every { registry.document(code) } returns codeRef
every { userRef.get() } returns Tasks.forResult(snapshot)
every { snapshot.getString("shareCode") } returns code
every { codeRef.set(any()) } returns Tasks.forResult(null)
every { scheduleRef.set(any()) } returns Tasks.forResult(null)
every { scheduleRef.update(any<Map<String, Any>>()) } returns Tasks.forResult(null)
repository = ScheduleRepository(auth, firestore)
}

@Test
fun `등록부 복구가 완료되기 전에 새 일정을 쓰지 않는다`() =
runTest {
val pending = TaskCompletionSource<Void>()
every { codeRef.set(any()) } returns pending.task

val result = async { 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<Schedule> { it.sharedCode == code && it.userId == uid }) }
}

@Test
fun `등록부 확인이 끝나야 편집을 저장한다`() =
runTest {
val pending = TaskCompletionSource<Void>()
every { codeRef.set(any()) } returns pending.task

val result = async { repository.updateSchedule(schedule) }
runCurrent()
assertFalse(result.isCompleted)
verify(exactly = 0) { scheduleRef.set(any()) }
verify(exactly = 0) { scheduleRef.update(any<Map<String, Any>>()) }
pending.setResult(null)

assertEquals(ScheduleRepository.ScheduleResult.Success(Unit), result.await())
}

@Test
fun `다른 계정의 이전 일정을 수정하려 하면 현재 계정으로 확인하고 쓰기를 막는다`() =
runTest {
val nextUser = mockk<FirebaseUser>()
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<Map<String, Any>>()) }
}

@Test
fun `등록부 충돌은 저장과 편집을 막고 코드 유지 안내를 돌려준다`() =
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<Map<String, Any>>()) }
}

@Test
fun `등록부 네트워크 실패도 일정 저장 없이 결과로 돌아온다`() =
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()) }
}

@Test
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<Schedule> { it.sharedCode == "" }) }
}

@Test
fun `코드 복구가 실패하면 빈 코드 일정 backfill을 시작하지 않는다`() =
runTest {
every { codeRef.set(any()) } returns Tasks.forException(denied())

assertEquals(0, repository.fillMissingSharedCode(uid))

verify(exactly = 0) { schedules.whereEqualTo(any<String>(), any()) }
verify(exactly = 0) { firestore.batch() }
}

@Test
fun `로그아웃 뒤에는 등록부와 일정 저장을 시작하지 않는다`() =
runTest {
every { auth.currentUser } returns null

assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.AuthError), repository.addSchedule(schedule))
assertEquals(ScheduleRepository.ScheduleResult.Error(AppError.AuthError), repository.updateSchedule(schedule))
verify(exactly = 0) { codeRef.set(any()) }
verify(exactly = 0) { scheduleRef.set(any()) }
verify(exactly = 0) { scheduleRef.update(any<Map<String, Any>>()) }
}

private fun denied() = FirebaseFirestoreException("denied", FirebaseFirestoreException.Code.PERMISSION_DENIED)
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class ScheduleRepositoryUpdateTest {
val firestore = mockk<FirebaseFirestore>()
every { firestore.collection("schedules") } returns schedules
every { firestore.collection("users") } returns mockk()
val registry = mockk<CollectionReference>()
val codeRef = mockk<DocumentReference>()
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)
}

Expand Down
Loading
Loading