diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 932ac6dcce..a882b74aa0 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -33,6 +33,7 @@ import com.afternote.feature.receiver.domain.model.ReceivedExportBundle import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyScreen import com.afternote.feature.receiver.presentation.deliveryverification.MasterKeyViewModel import com.afternote.feature.receiver.presentation.recordsbox.ReceivedRecordsScreen @@ -62,7 +63,8 @@ class ReceiverAdvancedAndroidTest { @Test fun senderRegistration_recordsEntryAndMasterKey_shareExactReceiverContext() { - val senderRegistry = SenderRegistry() + val senderRegistryRepository = FakeSenderRegistryRepository() + val senderRegistry = SenderRegistry(senderRegistryRepository) val registrationViewModel = SenderRegistrationViewModel(senderRegistry) val recordsViewModel = ReceivedRecordsViewModel(senderRegistry) val masterKeyResults = ArrayDeque>() @@ -141,7 +143,7 @@ class ReceiverAdvancedAndroidTest { composeRule.onNodeWithText("받은 기록함").assertIsDisplayed() composeRule.onNodeWithText("가족 별칭").assertIsDisplayed().performClick() - val sender = senderRegistry.senders.value.single() + val sender = senderRegistryRepository.senderEntries.value.single() assertEquals(1, registeredTransitions) assertEquals("가족 별칭", sender.name) assertEquals(sender.id, selectedSenderId) @@ -157,7 +159,7 @@ class ReceiverAdvancedAndroidTest { composeRule.waitUntil(timeoutMillis = 5_000) { verifiedTransitions == 1 } val normalizedMasterKey = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" - val attached = checkNotNull(senderRegistry.findById(sender.id)) + val attached = checkNotNull(senderRegistryRepository.senderEntries.value.firstOrNull { it.id == sender.id }) assertEquals(listOf(normalizedMasterKey), authRepository.verifiedMasterKeys) assertEquals(listOf(normalizedMasterKey), receiverRepository.savedMasterKeys) assertEquals(normalizedMasterKey, receiverRepository.masterKeyState.value) diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt index 79a1b7ad56..b9126a721d 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/AfternoteReceiverRepositoryModule.kt @@ -2,8 +2,10 @@ package com.afternote.feature.receiver.data.di import com.afternote.feature.receiver.data.repositoryimpl.IdentityVerificationRepositoryImpl import com.afternote.feature.receiver.data.repositoryimpl.ReceiverRepositoryImpl +import com.afternote.feature.receiver.data.repositoryimpl.SenderRegistryRepositoryImpl import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverRepository +import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -20,4 +22,8 @@ abstract class AfternoteReceiverRepositoryModule { @Binds @Singleton abstract fun bindIdentityVerificationRepository(impl: IdentityVerificationRepositoryImpl): IdentityVerificationRepository + + @Binds + @Singleton + abstract fun bindSenderRegistryRepository(impl: SenderRegistryRepositoryImpl): SenderRegistryRepository } diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt new file mode 100644 index 0000000000..74dd30cbe0 --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStore.kt @@ -0,0 +1,8 @@ +package com.afternote.feature.receiver.data.di + +import javax.inject.Qualifier + +/** Hilt 한정자: 발신자별 본인 확인 완료 상태 전용 `DataStore` 바인딩. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IdentityVerificationDataStore diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt new file mode 100644 index 0000000000..8494d26910 --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/IdentityVerificationDataStoreModule.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.receiver.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object IdentityVerificationDataStoreModule { + @Provides + @Singleton + @IdentityVerificationDataStore + fun provideIdentityVerificationDataStore(registry: LocalStoreRegistry): DataStore = + // 파일명·SESSION 수명 계약은 유지한다. 과거의 동적 boolean 키는 도달 불가능한 임시 ID용이라 + // 무시하고, 이제 재기동 뒤에도 안정적인 senderId를 아래 저장소의 새 단일 집합에 기록한다. + registry.store(name = "afternote_identity_verification", scope = StoreScope.SESSION) +} diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStore.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStore.kt new file mode 100644 index 0000000000..2134734d6e --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStore.kt @@ -0,0 +1,8 @@ +package com.afternote.feature.receiver.data.di + +import javax.inject.Qualifier + +/** Hilt 한정자: 발신자 카드 레지스트리 전용 `DataStore` 바인딩. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class SenderRegistryDataStore diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStoreModule.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStoreModule.kt new file mode 100644 index 0000000000..4267d3a26a --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/di/SenderRegistryDataStoreModule.kt @@ -0,0 +1,21 @@ +package com.afternote.feature.receiver.data.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object SenderRegistryDataStoreModule { + @Provides + @Singleton + @SenderRegistryDataStore + fun provideSenderRegistryDataStore(registry: LocalStoreRegistry): DataStore = + registry.store(name = "afternote_sender_registry", scope = StoreScope.SESSION) +} diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt index 5024ac2aae..b09f7c978c 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImpl.kt @@ -1,55 +1,64 @@ package com.afternote.feature.receiver.data.repositoryimpl +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.afternote.feature.receiver.data.di.IdentityVerificationDataStore import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton +private object IdentityVerificationKeys { + val VERIFIED_SENDER_IDS = stringSetPreferencesKey("identity_verified_sender_ids") +} + /** - * [IdentityVerificationRepository] 의 프로세스 수명 in-memory 구현. + * 발신자별 본인 확인 완료 상태를 SESSION Preferences DataStore에 보관한다. * * 발신자별 1회 검증 정책 (#597) — 발신자 A 인증이 발신자 B 의 관문을 열지 않도록 senderId 단위로 * 격리해 보관한다. * - * ## 왜 in-memory 인가 (#597 리뷰 반영) - * - * 키인 `senderId` 는 presentation 의 `SenderRegistry` 가 `UUID.randomUUID()` 로 발급하는 - * **프로세스 수명 값**이다 (registry 자체가 in-memory stub, #215). - * 앱 재시작 시 같은 발신자라도 재등록으로 새 UUID 를 받으므로, - * - 디스크에 남긴 이전 UUID 키는 **다시는 조회되지 않고** (영속의 실효 없음), - * - 인증 1회마다 죽은 키가 preferences 파일에 **단조 누적**되어 읽기 비용만 키운다. + * ## 왜 다시 영속인가 (#599) * - * 그래서 저장 수명을 키 발급처와 같은 프로세스 수명으로 맞춘다. #912 가 도입했던 DataStore - * (SESSION scope) 는 전역 boolean 시절의 「process death 후 유지」가 목적이었으나, 발신자별 - * UUID 키에서는 그 유지가 도달 불가능해 디스크 비용만 남는다. 프로세스를 넘는 발신자 식별자 - * (예: BE 발급 sender id, 또는 마스터 키의 비가역 파생값) 가 생기면 그때 영속을 되살린다. + * 키인 `senderId` 는 [SenderRegistryRepositoryImpl] 이 발급하는 로컬 카드 ID 다. #597 시점에는 그 + * 발급처가 in-memory stub 이라 앱을 재시작하면 같은 발신자도 새 UUID 를 받았고, 그래서 디스크에 남긴 + * 키는 «다시는 조회되지 않는 죽은 값» 으로 단조 누적되기만 했다 — 그 이유로 저장 수명을 프로세스에 + * 맞췄다. * - * 구 릴리스가 디스크에 남긴 전역 `identity_verified` boolean · `identity_verified_` 잔존값은 - * 본 구현이 디스크를 아예 읽지 않으므로 구조적으로 어떤 발신자의 관문도 열 수 없다. - * (잔존 파일 `afternote_identity_verification` 은 SESSION scope 매니페스트 등록이 디스크에 남아 있어 - * 다음 로그아웃 때 [com.afternote.core.datastore.LocalStoreRegistry.clearScope] 가 마저 비운다.) + * #599 가 그 전제를 없앴다. 카드 ID 가 재기동 뒤에도 그대로 복원되므로 같은 ID 로 저장한 확인 상태도 + * 재기동 뒤 유효하다. 그래서 SESSION scope 영속으로 되돌린다 — 로그아웃 시 + * [com.afternote.core.datastore.LocalStoreRegistry.clearScope] 가 카드와 함께 지운다. * * `@Singleton` — 열람 신청 흐름의 ViewModel 들이 같은 인스턴스를 공유해야 캐시가 의미를 갖는다. */ @Singleton class IdentityVerificationRepositoryImpl @Inject - constructor() : IdentityVerificationRepository { - private val verifiedSenderIds = MutableStateFlow>(emptySet()) - + constructor( + @param:IdentityVerificationDataStore private val dataStore: DataStore, + ) : IdentityVerificationRepository { override fun isVerified(senderId: String): Flow = - // 왜 Flow? 값이 시간에 따라 변하고 (verify 완료 시 false → true), - // UI 가 그 변화를 reactive 하게 받아 화면 갱신해야 해서. 단발 조회면 suspend 로 충분했지만 시계열. - verifiedSenderIds - .map { senderId in it } - // 다른 발신자의 markVerified 로 Set 이 바뀌어도 이 발신자의 boolean 이 그대로면 재방출하지 않는다. - .distinctUntilChanged() + dataStore.data + .map { preferences -> senderId in preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS].orEmpty() } + .catch { failure -> + if (failure is CancellationException) throw failure + if (failure is Exception) { + emit(false) + } else { + throw failure + } + }.distinctUntilChanged() override suspend fun markVerified(senderId: String) { - verifiedSenderIds.update { it + senderId } + dataStore.edit { preferences -> + val verified = preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS].orEmpty() + preferences[IdentityVerificationKeys.VERIFIED_SENDER_IDS] = verified + senderId + } } } diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImpl.kt new file mode 100644 index 0000000000..1ecda1762a --- /dev/null +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImpl.kt @@ -0,0 +1,179 @@ +package com.afternote.feature.receiver.data.repositoryimpl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.feature.receiver.data.di.SenderRegistryDataStore +import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry +import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private object SenderRegistryKeys { + val SNAPSHOT = stringPreferencesKey("sender_registry_snapshot") +} + +private const val CURRENT_SCHEMA_VERSION = 1 + +@Serializable +private data class SenderRegistrySnapshot( + val schemaVersion: Int = CURRENT_SCHEMA_VERSION, + val senders: List = emptyList(), +) + +@Serializable +private data class PersistedSenderEntry( + val id: String, + val name: String, + val masterKey: String? = null, + val realSenderName: String? = null, + val relation: String? = null, + val verificationStatus: String? = null, +) + +/** + * Preferences DataStore에 발신자 카드의 순서 있는 단일 JSON 스냅샷을 저장한다. + * + * 이 저장소만 별도 암호화하지 않는다. [com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource]와 + * 토큰 저장소도 같은 종류의 비밀을 평문 DataStore에 보관하므로, 여기만 암호화하면 수명·마이그레이션 + * 계약이 갈라진다. 저장소 전체를 아우르는 암호화 전환은 별도 시스템 마이그레이션으로 다룬다. + */ +@Singleton +class SenderRegistryRepositoryImpl + @Inject + constructor( + @param:SenderRegistryDataStore private val dataStore: DataStore, + ) : SenderRegistryRepository { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override val senders: Flow> = + dataStore.data + .map { preferences -> decode(preferences[SenderRegistryKeys.SNAPSHOT]) } + .catch { failure -> + if (failure is CancellationException) throw failure + if (failure is Exception) { + emit(emptyList()) + } else { + throw failure + } + } + + override suspend fun register(name: String): Result { + val entry = SenderEntry(id = UUID.randomUUID().toString(), name = name) + return runCatchingCancellable { + dataStore.edit { preferences -> + val current = decode(preferences[SenderRegistryKeys.SNAPSHOT]) + preferences[SenderRegistryKeys.SNAPSHOT] = encode(current + entry) + } + entry + } + } + + override suspend fun findById(id: String): Result = + runCatchingCancellable { + val preferences = dataStore.data.first() + decode(preferences[SenderRegistryKeys.SNAPSHOT]).firstOrNull { it.id == id } + } + + override suspend fun attachIdentity( + id: String, + masterKey: String, + identity: ReceiverIdentity, + ): Result = + updateById(id) { entry -> + entry.copy( + masterKey = masterKey, + realSenderName = identity.senderName, + relation = identity.relation, + ) + } + + override suspend fun updateVerificationStatus( + id: String, + status: DeliveryVerificationStatus, + ): Result = updateById(id) { it.copy(verificationStatus = status) } + + private suspend fun updateById( + id: String, + transform: (SenderEntry) -> SenderEntry, + ): Result = + runCatchingCancellable { + var updated: SenderEntry? = null + dataStore.edit { preferences -> + val current = decode(preferences[SenderRegistryKeys.SNAPSHOT]) + val next = + current.map { entry -> + if (entry.id == id) { + transform(entry).also { updated = it } + } else { + entry + } + } + if (updated != null) { + preferences[SenderRegistryKeys.SNAPSHOT] = encode(next) + } + } + updated + } + + private fun decode(raw: String?): List { + if (raw == null) return emptyList() + val snapshot = json.decodeFromString(raw) + if (snapshot.schemaVersion != CURRENT_SCHEMA_VERSION) { + throw UnsupportedSenderRegistrySchemaException(snapshot.schemaVersion) + } + return snapshot.senders.map(PersistedSenderEntry::toDomain) + } + + private fun encode(entries: List): String = + json.encodeToString( + SenderRegistrySnapshot( + senders = entries.map(SenderEntry::toPersisted), + ), + ) + } + +private class UnsupportedSenderRegistrySchemaException( + version: Int, +) : IllegalStateException("지원하지 않는 sender registry schemaVersion=$version") + +private fun PersistedSenderEntry.toDomain(): SenderEntry = + SenderEntry( + id = id, + name = name, + masterKey = masterKey, + realSenderName = realSenderName, + relation = relation, + // 우리가 [SenderEntry.toPersisted] 로 직접 쓴 `name` 을 되읽는다. 모르는 값이면 «캐시 없음» 으로 + // 낮춘다 — 옛 릴리스가 남긴 이름이 사라졌을 때 [DeliveryVerificationStatus.UNKNOWN] 으로 흡수하면 + // 「아직 신청 안 함」으로 그려져 버린다 (#1554 가 `fromRaw` 를 걷어낸 것과 같은 이유). + verificationStatus = verificationStatus?.let(DeliveryVerificationStatus::fromWireOrNull), + ) + +private fun SenderEntry.toPersisted(): PersistedSenderEntry = + PersistedSenderEntry( + id = id, + name = name, + masterKey = masterKey, + realSenderName = realSenderName, + relation = relation, + verificationStatus = verificationStatus?.name, + ) diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImplTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImplTest.kt index eea7cf8a00..3dc38df177 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImplTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/IdentityVerificationRepositoryImplTest.kt @@ -1,26 +1,34 @@ package com.afternote.feature.receiver.data.repositoryimpl +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.job import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException -/** - * 본인 확인 캐시의 발신자별 격리 (#597). - * - * 이전 구현은 전역 `identity_verified` boolean 하나라 발신자 A 인증만으로 - * 발신자 B 의 이메일 관문까지 열렸다 — 발신자 단위 격리가 실제 구현에서 지켜지는지 본다. - * - * 구 릴리스가 디스크에 남긴 전역 boolean · 옛 UUID 키 잔존값 가드는 별도 테스트가 없다 — - * 본 구현은 디스크를 아예 읽지 않아 (프로세스 수명 in-memory, #597 리뷰 반영) 잔존값이 - * 관문에 닿을 경로 자체가 없다. 아래 «비어 시작한다» 가 그 전제(초기 상태에 어떤 발신자도 - * 열려 있지 않음)를 고정한다. - */ +/** 발신자별 본인 확인 격리와 SESSION DataStore 재기동 복원 계약. */ class IdentityVerificationRepositoryImplTest { + @get:Rule + val tmp = TemporaryFolder() + @Test fun `A 발신자 인증이 B 발신자 관문을 열지 않는다`() { - val repository = IdentityVerificationRepositoryImpl() + val repository = IdentityVerificationRepositoryImpl(InMemoryPreferencesDataStore()) runBlocking { repository.markVerified("sender-a") } @@ -30,7 +38,7 @@ class IdentityVerificationRepositoryImplTest { @Test fun `발신자별 인증은 각자 독립적으로 누적된다`() { - val repository = IdentityVerificationRepositoryImpl() + val repository = IdentityVerificationRepositoryImpl(InMemoryPreferencesDataStore()) runBlocking { repository.markVerified("sender-a") @@ -43,17 +51,39 @@ class IdentityVerificationRepositoryImplTest { } @Test - fun `새 저장소는 어떤 발신자의 관문도 미리 열어 두지 않는다`() { - val repository = IdentityVerificationRepositoryImpl() + fun `실물 파일 DataStore 재생성 후 같은 발신자 ID의 인증 상태를 복원한다`() { + val file = File(tmp.root, "identity-verification.preferences_pb") + val firstScope = newStoreScope() + try { + runBlocking { + val first = IdentityVerificationRepositoryImpl(newFileDataStore(file, firstScope)) + first.markVerified("persistent-sender-id") + } + } finally { + shutdown(firstScope) + } + + val secondScope = newStoreScope() + try { + val recreated = IdentityVerificationRepositoryImpl(newFileDataStore(file, secondScope)) + + assertTrue(runBlocking { recreated.isVerified("persistent-sender-id").first() }) + assertFalse(runBlocking { recreated.isVerified("different-sender-id").first() }) + } finally { + shutdown(secondScope) + } + } + + @Test + fun `DataStore 읽기 실패는 미인증으로 방출한다`() { + val repository = IdentityVerificationRepositoryImpl(ReadFailingIdentityDataStore()) assertFalse(runBlocking { repository.isVerified("sender-a").first() }) } @Test - fun `markVerified 이전에 얻어 둔 Flow 도 갱신된 값을 준다`() { - // isVerified 반환 Flow 는 호출 시점 스냅숏이 아니다 — Intro 게이트가 stateIn 으로 - // 계속 물고 있는 실사용 형태와 같은 성질(시계열)을 값 수준에서 고정한다. - val repository = IdentityVerificationRepositoryImpl() + fun `markVerified 이전에 얻어 둔 Flow도 갱신된 값을 준다`() { + val repository = IdentityVerificationRepositoryImpl(InMemoryPreferencesDataStore()) runBlocking { val flow = repository.isVerified("sender-a") @@ -64,4 +94,37 @@ class IdentityVerificationRepositoryImplTest { assertTrue(flow.first()) } } + + private fun newStoreScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun newFileDataStore( + file: File, + scope: CoroutineScope, + ): DataStore = PreferenceDataStoreFactory.create(scope = scope) { file } + + private fun shutdown(scope: CoroutineScope) { + runBlocking { + val job = scope.coroutineContext.job + job.cancel() + job.join() + } + } +} + +private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } +} + +private class ReadFailingIdentityDataStore : DataStore { + override val data: Flow = flow { throw IOException("read failed") } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = emptyPreferences() } diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImplTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImplTest.kt new file mode 100644 index 0000000000..2bb7cb9144 --- /dev/null +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/SenderRegistryRepositoryImplTest.kt @@ -0,0 +1,183 @@ +package com.afternote.feature.receiver.data.repositoryimpl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +class SenderRegistryRepositoryImplTest { + @get:Rule + val tmp = TemporaryFolder() + + private val snapshotKey = stringPreferencesKey("sender_registry_snapshot") + + @Test + fun `실물 파일 DataStore 재생성 후 모든 카드 필드와 등록 순서를 복원한다`() { + val file = File(tmp.root, "sender-registry.preferences_pb") + val firstScope = newStoreScope() + val firstStore = newFileDataStore(file, firstScope) + val expected = + try { + runBlocking { + val repository = SenderRegistryRepositoryImpl(firstStore) + val first = repository.register("첫 번째 별칭").getOrThrow() + repository + .attachIdentity( + id = first.id, + masterKey = "master-key-1", + identity = + ReceiverIdentity( + receiverId = 11L, + receiverName = "수신자", + senderName = "실제 발신자 1", + relation = "부", + ), + ).getOrThrow()!! + val completedFirst = + repository + .updateVerificationStatus(first.id, DeliveryVerificationStatus.APPROVED) + .getOrThrow()!! + + val second = repository.register("두 번째 별칭").getOrThrow() + val completedSecond = + repository + .attachIdentity( + id = second.id, + masterKey = "master-key-2", + identity = + ReceiverIdentity( + receiverId = 22L, + receiverName = "다른 수신자", + senderName = "실제 발신자 2", + relation = "친구", + ), + ).getOrThrow()!! + + listOf(completedFirst, completedSecond) + } + } finally { + shutdown(firstScope) + } + + val secondScope = newStoreScope() + try { + val recreated = SenderRegistryRepositoryImpl(newFileDataStore(file, secondScope)) + + assertEquals(expected, runBlocking { recreated.senders.first() }) + } finally { + shutdown(secondScope) + } + } + + @Test + fun `손상 JSON 읽기는 빈 목록이며 후속 쓰기 실패 시 원문 payload를 보존한다`() { + val scope = newStoreScope() + try { + val dataStore = newFileDataStore(File(tmp.root, "malformed.preferences_pb"), scope) + val malformed = "{ not-valid-json" + runBlocking { dataStore.edit { it[snapshotKey] = malformed } } + val repository = SenderRegistryRepositoryImpl(dataStore) + + assertEquals(emptyList(), runBlocking { repository.senders.first() }) + + val write = runBlocking { repository.register("덮어쓰면 안 됨") } + assertTrue(write.isFailure) + assertEquals(malformed, runBlocking { dataStore.data.first()[snapshotKey] }) + } finally { + shutdown(scope) + } + } + + @Test + fun `DataStore 읽기 실패는 빈 목록을 방출한다`() { + val repository = SenderRegistryRepositoryImpl(ReadFailingDataStore(IOException("read failed"))) + + assertEquals(emptyList(), runBlocking { repository.senders.first() }) + } + + @Test + fun `알 수 없는 저장 상태는 캐시 없음으로 낮추고 새 필드는 무시한다`() { + val scope = newStoreScope() + try { + val dataStore = newFileDataStore(File(tmp.root, "future-status.preferences_pb"), scope) + runBlocking { + dataStore.edit { + it[snapshotKey] = + """ + { + "schemaVersion": 1, + "futureSnapshotField": true, + "senders": [ + { + "id": "sender-1", + "name": "별칭", + "verificationStatus": "WAITING_FOR_FUTURE", + "futureEntryField": 123 + } + ] + } + """.trimIndent() + } + } + val repository = SenderRegistryRepositoryImpl(dataStore) + + assertEquals( + listOf( + SenderEntry( + // 모르는 상태 이름은 «캐시 없음»(null) 이다. UNKNOWN 으로 흡수하면 화면이 + // 「아직 신청 안 함」으로 그려진다 (#1554 가 `fromRaw` 를 걷어낸 이유). + id = "sender-1", + name = "별칭", + verificationStatus = null, + ), + ), + runBlocking { repository.senders.first() }, + ) + } finally { + shutdown(scope) + } + } + + private fun newStoreScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun newFileDataStore( + file: File, + scope: CoroutineScope, + ): DataStore = PreferenceDataStoreFactory.create(scope = scope) { file } + + private fun shutdown(scope: CoroutineScope) { + runBlocking { + val job = scope.coroutineContext.job + job.cancel() + job.join() + } + } +} + +private class ReadFailingDataStore( + private val failure: Exception, +) : DataStore { + override val data: Flow = flow { throw failure } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences = emptyPreferences() +} diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/SenderEntry.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/SenderEntry.kt new file mode 100644 index 0000000000..ec645d401a --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/SenderEntry.kt @@ -0,0 +1,19 @@ +package com.afternote.feature.receiver.domain.model + +/** + * 받은 기록함에 사용자가 등록한 발신자 카드. + * + * [id]는 백엔드 발신자 ID가 아니라 이 로컬 카드의 안정적인 식별자다. 저장소가 이 값을 + * 프로세스 재시작 뒤에도 복원하므로, 발신자별 본인 확인 상태도 같은 ID로 다시 찾을 수 있다. + * + * [masterKey]와 신원 필드는 마스터 키 검증 성공 뒤 채워지고, [verificationStatus]는 가장 최근에 + * 조회한 열람 신청 상태를 보관한다. 카드 별칭인 [name]은 동명이인을 위해 중복을 허용한다. + */ +data class SenderEntry( + val id: String, + val name: String, + val masterKey: String? = null, + val realSenderName: String? = null, + val relation: String? = null, + val verificationStatus: DeliveryVerificationStatus? = null, +) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/IdentityVerificationRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/IdentityVerificationRepository.kt index 1501c1f96f..b8011dcdc2 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/IdentityVerificationRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/IdentityVerificationRepository.kt @@ -9,16 +9,14 @@ import kotlinx.coroutines.flow.Flow * 발신자 A 의 인증 결과가 발신자 B 의 관문을 열어서는 안 된다 — 이전의 전역 boolean 하나짜리 * 캐시는 한 발신자 인증만으로 모든 발신자의 Intro·이메일 단계를 건너뛰게 했다. * - * 캐시 수명 = **프로세스** (#597 리뷰 반영). 키인 `senderId` 자체가 in-memory `SenderRegistry` 발급 - * UUID 라 프로세스를 넘어 같은 발신자를 가리키지 못한다 — 디스크에 영속해도 재시작 후 재조회가 - * 불가능해 (#912 가 도입했던 DataStore 영속은) 죽은 키 누적 비용만 남아, 저장을 키 수명에 맞췄다. - * process death · 앱 재시작 후에는 발신자 재등록과 함께 본인 확인도 다시 진행한다. - * 프로세스를 넘는 발신자 식별자가 생기면 그때 영속을 되살린다. + * 저장 수명 = **로그인 SESSION**. 발신자 카드 저장소가 로컬 `senderId`를 프로세스 재시작 뒤에도 + * 그대로 복원하므로, 같은 ID에 귀속된 본인 확인 완료 상태도 앱 재기동 뒤 다시 찾을 수 있다. + * 로그아웃·회원 탈퇴 때 SESSION 저장소가 함께 지워져 다음 계정으로 상태가 넘어가지 않는다. */ interface IdentityVerificationRepository { - /** [senderId] 발신자에 대한 본인 확인 완료 여부. 프로세스 수명 캐시 — 앱 재시작 시 재인증. */ + /** [senderId] 발신자에 대한 본인 확인 완료 여부. 저장소 읽기 실패는 안전하게 `false`로 본다. */ fun isVerified(senderId: String): Flow - /** [senderId] 발신자에 대한 본인 확인 완료 표시. 프로세스 수명 캐시에 기록한다. */ + /** [senderId] 발신자에 대한 본인 확인 완료 표시. 로그인 SESSION에 영속한다. */ suspend fun markVerified(senderId: String) } diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/SenderRegistryRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/SenderRegistryRepository.kt new file mode 100644 index 0000000000..26c929c75b --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/SenderRegistryRepository.kt @@ -0,0 +1,36 @@ +package com.afternote.feature.receiver.domain.repository + +import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry +import kotlinx.coroutines.flow.Flow + +/** + * 받은 기록함 발신자 카드를 저장하고 관찰하는 계약. + * + * 이 계약은 로컬 카드 자체의 수명만 다룬다. 수신자별 저장 격리(#598)는 별도 계약이며, + * 여기에는 수신자 식별자를 추가하지 않는다. + */ +interface SenderRegistryRepository { + /** 등록 순서를 유지한 발신자 카드 목록. 읽기 실패 시 구현은 안전한 빈 목록을 방출한다. */ + val senders: Flow> + + /** 사용자가 지정한 카드 별칭 [name]으로 새 로컬 카드를 등록한다. */ + suspend fun register(name: String): Result + + /** 로컬 카드 ID로 조회한다. 일치하는 카드가 없으면 성공한 `null`을 반환한다. */ + suspend fun findById(id: String): Result + + /** 마스터 키 검증 결과를 카드에 결합한다. 카드가 없으면 성공한 `null`을 반환한다. */ + suspend fun attachIdentity( + id: String, + masterKey: String, + identity: ReceiverIdentity, + ): Result + + /** 최근 열람 신청 [status]를 카드에 기록한다. 카드가 없으면 성공한 `null`을 반환한다. */ + suspend fun updateVerificationStatus( + id: String, + status: DeliveryVerificationStatus, + ): Result +} diff --git a/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/testing/FakeReceiverRepositoriesTest.kt b/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/testing/FakeReceiverRepositoriesTest.kt index 6bbb2eb6b9..1072952541 100644 --- a/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/testing/FakeReceiverRepositoriesTest.kt +++ b/feature/receiver/domain/src/test/java/com/afternote/feature/receiver/domain/testing/FakeReceiverRepositoriesTest.kt @@ -1,5 +1,8 @@ package com.afternote.feature.receiver.domain.testing +import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first @@ -149,4 +152,22 @@ class FakeReceiverRepositoriesTest { runBlocking { authRepository.getSenderMessage() } } } + + @Test + fun `SenderRegistry strict fixture의 flow는 안전하고 suspend 호출은 failure Result를 반환한다`() { + val initial = SenderEntry(id = "sender-1", name = "별칭") + val repository = FakeSenderRegistryRepository.strict(initialSenders = listOf(initial)) + val identity = ReceiverIdentity(1L, "수신자", "발신자", "친구") + + assertEquals(listOf(initial), runBlocking { repository.senders.first() }) + assertTrue(runBlocking { repository.register("새 별칭") }.isFailure) + assertTrue(runBlocking { repository.findById(initial.id) }.isFailure) + assertTrue(runBlocking { repository.attachIdentity(initial.id, "key", identity) }.isFailure) + assertTrue( + runBlocking { + repository.updateVerificationStatus(initial.id, DeliveryVerificationStatus.APPROVED) + }.isFailure, + ) + assertEquals(listOf(initial), repository.senderEntries.value) + } } diff --git a/feature/receiver/domain/src/testFixtures/kotlin/com/afternote/feature/receiver/domain/testing/FakeSenderRegistryRepository.kt b/feature/receiver/domain/src/testFixtures/kotlin/com/afternote/feature/receiver/domain/testing/FakeSenderRegistryRepository.kt new file mode 100644 index 0000000000..bfe2c47c09 --- /dev/null +++ b/feature/receiver/domain/src/testFixtures/kotlin/com/afternote/feature/receiver/domain/testing/FakeSenderRegistryRepository.kt @@ -0,0 +1,104 @@ +package com.afternote.feature.receiver.domain.testing + +import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus +import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry +import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID + +/** + * [SenderRegistryRepository] fake 정본. + * + * 기본 경로는 [senderEntries]에 등록 순서대로 카드를 보관하는 성공 구현이다. 특수 시나리오만 + * `onX` 콜백으로 바꾼다. [strict]도 Flow 조회는 안전한 빈 목록으로 유지하며, 열지 않은 suspend + * 호출은 예외를 던지지 않고 [Result.failure]로 돌려 androidTest 프로세스 크래시를 막는다. + */ +class FakeSenderRegistryRepository( + initialSenders: List = emptyList(), + var onRegister: (suspend (name: String) -> Result)? = null, + var onFindById: (suspend (id: String) -> Result)? = null, + var onAttachIdentity: (suspend (id: String, masterKey: String, identity: ReceiverIdentity) -> Result)? = null, + var onUpdateVerificationStatus: (suspend (id: String, status: DeliveryVerificationStatus) -> Result)? = null, +) : SenderRegistryRepository { + val senderEntries = MutableStateFlow(initialSenders.toList()) + + override val senders: Flow> = senderEntries.asStateFlow() + + override suspend fun register(name: String): Result { + onRegister?.let { return it(name) } + + val entry = SenderEntry(id = UUID.randomUUID().toString(), name = name) + senderEntries.update { it + entry } + return Result.success(entry) + } + + override suspend fun findById(id: String): Result = + onFindById?.invoke(id) ?: Result.success(senderEntries.value.firstOrNull { it.id == id }) + + override suspend fun attachIdentity( + id: String, + masterKey: String, + identity: ReceiverIdentity, + ): Result { + onAttachIdentity?.let { return it(id, masterKey, identity) } + return Result.success( + updateById(id) { entry -> + entry.copy( + masterKey = masterKey, + realSenderName = identity.senderName, + relation = identity.relation, + ) + }, + ) + } + + override suspend fun updateVerificationStatus( + id: String, + status: DeliveryVerificationStatus, + ): Result { + onUpdateVerificationStatus?.let { return it(id, status) } + return Result.success(updateById(id) { it.copy(verificationStatus = status) }) + } + + private inline fun updateById( + id: String, + transform: (SenderEntry) -> SenderEntry, + ): SenderEntry? { + var updated: SenderEntry? = null + senderEntries.update { entries -> + entries.map { entry -> + if (entry.id == id) { + transform(entry).also { updated = it } + } else { + entry + } + } + } + return updated + } + + companion object { + /** 열지 않은 suspend 경로를 모두 실패 [Result]로 반환하는 crash-safe strict fake. */ + fun strict(initialSenders: List = emptyList()): FakeSenderRegistryRepository = + FakeSenderRegistryRepository( + initialSenders = initialSenders, + onRegister = { Result.failure(unexpectedResultCall("SenderRegistryRepository.register")) }, + onFindById = { Result.failure(unexpectedResultCall("SenderRegistryRepository.findById")) }, + onAttachIdentity = { _, _, _ -> + Result.failure(unexpectedResultCall("SenderRegistryRepository.attachIdentity")) + }, + onUpdateVerificationStatus = { _, _ -> + Result.failure(unexpectedResultCall("SenderRegistryRepository.updateVerificationStatus")) + }, + ) + + private fun unexpectedResultCall(method: String): IllegalStateException = + IllegalStateException( + "$method 는 이 시나리오에서 호출되면 안 됨", + ) + } +} diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt index b14127d4fb..31e7513124 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModel.kt @@ -71,8 +71,31 @@ class MasterKeyViewModel .verifyMasterKey(trimmed) .onSuccess { identity -> receiverRepository.saveMasterKey(trimmed) - senderRegistry.attachIdentity(senderId, trimmed, identity) - _uiState.update { it.copy(isSubmitting = false, isVerified = true) } + senderRegistry + .attachIdentity(senderId, trimmed, identity) + .fold( + onSuccess = { sender -> + _uiState.update { + if (sender == null) { + it.copy( + isSubmitting = false, + error = UiText.Resource(R.string.receiver_verify_error_unknown), + ) + } else { + it.copy(isSubmitting = false, isVerified = true) + } + } + }, + onFailure = { throwable -> + errorReporter.recordAfternoteFailure(AfternoteFailureStage.MASTER_KEY_VERIFY, throwable) + _uiState.update { + it.copy( + isSubmitting = false, + error = UiText.Resource(R.string.receiver_verify_error_unknown), + ) + } + }, + ) }.onFailure { throwable -> if (throwable.shouldReportInReceiverFlow()) { errorReporter.recordAfternoteFailure(AfternoteFailureStage.MASTER_KEY_VERIFY, throwable) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt index 9cebead8d1..f490f7be24 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/navigation/model/ReceiverRoute.kt @@ -25,7 +25,7 @@ sealed interface ReceiverRoute { /** * 발신자 등록(15·16) — 받은 기록함의 FAB 에서 진입. 사용자가 발신자에게 부여하는 별칭(라벨) 입력. * - * 백엔드 *발신자 라벨 등록 API* 가 미확정이라 1단계는 클라 로컬 stub registry 에만 보관한다. + * 백엔드 *발신자 라벨 등록 API* 가 미확정이라 1단계는 클라 로컬 registry 에 영속한다. */ @Serializable data object SenderRegistrationRoute : ReceiverRoute @@ -68,7 +68,8 @@ sealed interface ReceiverRoute { * * 인증 성공 시 [com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository] * 캐시가 해당 발신자에 대해 켜져 같은 발신자 재진입 시 마스터 키로 직진 (#597 — 발신자별 격리, - * 다른 발신자 흐름에서는 다시 인증). 캐시는 프로세스 수명 — 앱 재시작 후에는 다시 인증한다. + * 다른 발신자 흐름에서는 다시 인증). 카드 ID 와 인증 캐시는 같은 세션 수명으로 + * 영속하며, 로그아웃 후 다음 계정에는 승계하지 않는다. */ @Serializable data object IdentityVerificationEmailRoute : ReceiverRoute diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt index 9ca286d72f..4928753188 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModel.kt @@ -1,15 +1,17 @@ package com.afternote.feature.receiver.presentation.recordsbox import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject /** * 받은 기록함 화면 ViewModel — 등록된 발신자 카드 리스트 노출 (이슈 #215). * - * 현재는 [SenderRegistry] 의 in-memory 데이터를 그대로 흘려보낸다. 백엔드 *발신자 리스트 조회 API* - * 가 확정되면 Repository 호출로 교체한다. + * [SenderRegistry] 의 영속 목록 Flow 를 화면 수명 [StateFlow]로 바꿔 노출한다. */ @HiltViewModel class ReceivedRecordsViewModel @@ -17,5 +19,10 @@ class ReceivedRecordsViewModel constructor( senderRegistry: SenderRegistry, ) : ViewModel() { - val senders: StateFlow> = senderRegistry.senders + val senders: StateFlow> = + senderRegistry.senders.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt index fce591d141..dcc20f5628 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderEntry.kt @@ -1,28 +1,4 @@ package com.afternote.feature.receiver.presentation.recordsbox -import androidx.compose.runtime.Immutable -import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus - -/** - * 받은 기록함 카드 한 줄의 표시 데이터. - * - * `id` 는 클라 로컬에서 발급하는 식별자(백엔드 발신자 ID 와 별개). 서버 매칭은 마스터 키 검증 시점에 - * `verify(masterKey)` 응답으로 이뤄지므로, 카드 자체는 사용자가 부여한 *별칭* 만 보관한다. - * - * `masterKey` 는 마스터 키 검증 성공 후 채워지며, "기록 열람하기" 진입 시 [com.afternote.feature.afternote - * .domain.repository.receiver.ReceiverRepository.saveMasterKey] 로 글로벌 헤더 컨텍스트에 복원한다. - * `realSenderName`·`relation` 은 [com.afternote.feature.receiver.domain.model.ReceiverIdentity] - * 응답에서 수신한 값으로 발신자 상세(11·12)의 인사말 및 관계 표기에 사용한다. - * - * `verificationStatus` 는 가장 최근에 조회한 열람 신청 상태 캐시. 발신자 상세 진입 시 서버 호출 결과로 - * 갱신되며, 카드 리스트 자체에서는 표시하지 않는다. - */ -@Immutable -data class SenderEntry( - val id: String, - val name: String, - val masterKey: String? = null, - val realSenderName: String? = null, - val relation: String? = null, - val verificationStatus: DeliveryVerificationStatus? = null, -) +/** 기존 presentation 패키지 소비처와의 소스 호환을 위한 도메인 모델 별칭. */ +typealias SenderEntry = com.afternote.feature.receiver.domain.model.SenderEntry diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt index 2984de7afa..ed96b2153b 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistrationViewModel.kt @@ -35,8 +35,9 @@ class SenderRegistrationViewModel val trimmed = name.trim() if (trimmed.isEmpty()) return viewModelScope.launch { - senderRegistry.register(trimmed) - _uiState.update { it.copy(isRegistered = true) } + senderRegistry + .register(trimmed) + .onSuccess { _uiState.update { it.copy(isRegistered = true) } } } } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt index 398c895cd1..3ca369e54d 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/recordsbox/SenderRegistry.kt @@ -2,84 +2,50 @@ package com.afternote.feature.receiver.presentation.recordsbox import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverIdentity -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import java.util.UUID +import com.afternote.feature.receiver.domain.repository.SenderRegistryRepository +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton /** - * 받은 기록함의 발신자 카드 in-memory stub registry (이슈 #215). + * 받은 기록함의 발신자 카드 접근 창구. * - * 백엔드의 *발신자 라벨 등록 / 발신자 리스트 조회* API 가 미확정이라, 발신자 등록·조회·캐시를 - * 일단 프로세스 메모리에 보관한다. 앱 재시작 시 사라지는 한계가 있으나 디자인 흐름 확인에는 충분. + * 백엔드의 *발신자 라벨 등록 / 발신자 리스트 조회* API 가 미확정이라 등록·조회는 + * 아직 로컬 구현이 담당한다. 실제 DataStore 스키마와 오류 처리는 presentation 밖의 + * [SenderRegistryRepository]에 숨겨, 화면은 저장 방식을 알지 못한다. * - * 백엔드 API 가 확정되면 본 클래스를 [com.afternote.feature.afternote.domain] 레이어의 - * Repository 구현으로 대체한다. - * - * `@Singleton` 으로 두어 ViewModel 간 동일 인스턴스를 공유한다 (Hilt 기본 SingletonComponent). + * #598의 수신자별 격리 계약은 여기에 추가하지 않는다. 이 창구는 #599의 프로세스 재시작 + * 복원만 담당한다. */ @Singleton class SenderRegistry @Inject - constructor() { - private val _senders = MutableStateFlow>(emptyList()) - val senders: StateFlow> = _senders + constructor( + private val repository: SenderRegistryRepository, + ) { + val senders: Flow> = repository.senders /** * 새 발신자 등록. 이름은 호출자가 trim·검증한 값을 넘긴다. 동일 이름 중복 허용 (사용자가 * 부여하는 별칭이라 동명이인 발신자 있을 수 있음). */ - fun register(name: String): SenderEntry { - val entry = - SenderEntry( - id = UUID.randomUUID().toString(), - name = name, - ) - _senders.update { it + entry } - return entry - } + suspend fun register(name: String): Result = repository.register(name) - fun findById(id: String): SenderEntry? = _senders.value.firstOrNull { it.id == id } + suspend fun findById(id: String): Result = repository.findById(id) /** * 마스터 키 검증 성공 직후 호출. masterKey + verify 응답 정보를 카드에 결합한다. 이후 * "기록 열람하기"(12) 진입 시 이 masterKey 를 ReceiverRepository 에 복원해 헤더 컨텍스트를 잡는다. */ - fun attachIdentity( + suspend fun attachIdentity( id: String, masterKey: String, identity: ReceiverIdentity, - ): SenderEntry? = - updateById(id) { entry -> - entry.copy( - masterKey = masterKey, - realSenderName = identity.senderName, - relation = identity.relation, - ) - } + ): Result = repository.attachIdentity(id = id, masterKey = masterKey, identity = identity) /** 발신자 상세에서 최근 조회한 열람 신청 상태를 캐시. */ - fun updateVerificationStatus( + suspend fun updateVerificationStatus( id: String, status: DeliveryVerificationStatus, - ): SenderEntry? = updateById(id) { entry -> entry.copy(verificationStatus = status) } - - private inline fun updateById( - id: String, - transform: (SenderEntry) -> SenderEntry, - ): SenderEntry? { - var updated: SenderEntry? = null - _senders.update { list -> - list.map { entry -> - if (entry.id == id) { - transform(entry).also { updated = it } - } else { - entry - } - } - } - return updated - } + ): Result = repository.updateVerificationStatus(id = id, status = status) } diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt index 8481e90073..d9a8f4d5f5 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModel.kt @@ -90,9 +90,14 @@ class SenderDetailViewModel * masterKey 가 없는 경우(미인증) 호출되어선 안 되지만 방어적으로 no-op. */ fun openReceiverHome() { - val masterKey = senderRegistry.findById(senderId)?.masterKey - if (masterKey.isNullOrBlank()) return viewModelScope.launch { + val masterKey = + senderRegistry + .findById(senderId) + .onFailure { errorReporter.recordAfternoteFailure(AfternoteFailureStage.SENDER_STATUS_LOAD, it) } + .getOrNull() + ?.masterKey + if (masterKey.isNullOrBlank()) return@launch receiverRepository.saveMasterKey(masterKey) _uiState.update { current -> if (current is SenderDetailUiState.Success) { @@ -119,17 +124,23 @@ class SenderDetailViewModel keepsStateOnFailure: Boolean = false, ) { loadJob?.cancel() - val sender = senderRegistry.findById(senderId) - if (sender == null) { - _uiState.value = SenderDetailUiState.SenderNotFound - return - } if (showsLoading) { _uiState.value = SenderDetailUiState.Loading } loadJob = viewModelScope.launch { - val resolved = resolveState(sender) + val sender = + senderRegistry + .findById(senderId) + .onFailure { + errorReporter.recordAfternoteFailure(AfternoteFailureStage.SENDER_STATUS_LOAD, it) + }.getOrNull() + val resolved = + if (sender == null) { + SenderDetailUiState.SenderNotFound + } else { + resolveState(sender) + } _uiState.update { current -> when { // 자동 갱신의 조회 실패: 잘 보고 있던 정보 박스를 에러로 대체하지 않는다. @@ -201,7 +212,11 @@ class SenderDetailViewModel val statusResult = receiverAuthRepository.getDeliveryVerificationStatus() return statusResult.fold( onSuccess = { verification -> - senderRegistry.updateVerificationStatus(sender.id, verification.status) + senderRegistry + .updateVerificationStatus(sender.id, verification.status) + .onFailure { + errorReporter.recordAfternoteFailure(AfternoteFailureStage.SENDER_STATUS_LOAD, it) + } verification.toSuccessState( displayName = displayName, approvedAt = resolveApprovedAt(verification.status, masterKey), diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt index c0ac11196d..55b8f2845e 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/MasterKeyViewModelTest.kt @@ -2,12 +2,15 @@ package com.afternote.feature.receiver.presentation.deliveryverification import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText +import com.afternote.feature.receiver.domain.model.SenderEntry import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository import com.afternote.feature.receiver.presentation.R import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -54,8 +57,8 @@ class MasterKeyViewModelTest { fun `정규 UUID 마스터 키는 공백을 제거해 검증하고 저장한다`() { val authRepository = fakeReceiverAuthRepository() val receiverRepository = fakeReceiverRepository() - val senderRegistry = SenderRegistry() - val sender = senderRegistry.register("별칭") + val sender = SenderEntry(id = "sender-id", name = "별칭") + val senderRegistry = senderRegistry(listOf(sender)) val masterKey = "123e4567-e89b-12d3-a456-426614174000" val viewModel = viewModel(authRepository, receiverRepository, senderRegistry) @@ -63,7 +66,10 @@ class MasterKeyViewModelTest { assertEquals(listOf(masterKey), authRepository.verifiedMasterKeys) assertEquals(listOf(masterKey), receiverRepository.savedMasterKeys) - assertEquals("발신자", senderRegistry.findById(sender.id)?.realSenderName) + assertEquals( + "발신자", + runBlocking { senderRegistry.findById(sender.id).getOrThrow()?.realSenderName }, + ) assertTrue(viewModel.uiState.value.isVerified) assertFalse(viewModel.uiState.value.isSubmitting) assertEquals(null, viewModel.uiState.value.error) @@ -73,8 +79,8 @@ class MasterKeyViewModelTest { fun `대문자 마스터 키는 소문자로 정규화해 검증하고 저장한다`() { val authRepository = fakeReceiverAuthRepository() val receiverRepository = fakeReceiverRepository() - val senderRegistry = SenderRegistry() - val sender = senderRegistry.register("별칭") + val sender = SenderEntry(id = "sender-id", name = "별칭") + val senderRegistry = senderRegistry(listOf(sender)) val viewModel = viewModel(authRepository, receiverRepository, senderRegistry) viewModel.submit(senderId = sender.id, masterKey = "123E4567-E89B-12D3-A456-426614174000") @@ -86,10 +92,34 @@ class MasterKeyViewModelTest { assertEquals(null, viewModel.uiState.value.error) } + @Test + fun `마스터 키 검증 후 카드 저장이 실패하면 검증 완료로 이동하지 않는다`() { + val authRepository = fakeReceiverAuthRepository() + val receiverRepository = fakeReceiverRepository() + val sender = SenderEntry(id = "sender-id", name = "별칭") + val registryRepository = + FakeSenderRegistryRepository(initialSenders = listOf(sender)).apply { + onAttachIdentity = { _, _, _ -> Result.failure(IllegalStateException("write failed")) } + } + val viewModel = viewModel(authRepository, receiverRepository, SenderRegistry(registryRepository)) + + viewModel.submit( + senderId = sender.id, + masterKey = "123e4567-e89b-12d3-a456-426614174000", + ) + + assertFalse(viewModel.uiState.value.isVerified) + assertFalse(viewModel.uiState.value.isSubmitting) + assertEquals( + UiText.Resource(R.string.receiver_verify_error_unknown), + viewModel.uiState.value.error, + ) + } + private fun viewModel( authRepository: FakeReceiverAuthRepository, receiverRepository: FakeReceiverRepository, - senderRegistry: SenderRegistry = SenderRegistry(), + senderRegistry: SenderRegistry = senderRegistry(), ): MasterKeyViewModel = MasterKeyViewModel( senderRegistry = senderRegistry, @@ -98,6 +128,9 @@ class MasterKeyViewModelTest { errorReporter = NoopErrorReporter, ) + private fun senderRegistry(initialSenders: List = emptyList()): SenderRegistry = + SenderRegistry(FakeSenderRegistryRepository(initialSenders = initialSenders)) + private fun fakeReceiverAuthRepository(): FakeReceiverAuthRepository = FakeReceiverAuthRepository.strict().apply { onVerifyMasterKey = { Result.success(identity) } diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModelTest.kt new file mode 100644 index 0000000000..f07f93e440 --- /dev/null +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/recordsbox/ReceivedRecordsViewModelTest.kt @@ -0,0 +1,47 @@ +package com.afternote.feature.receiver.presentation.recordsbox + +import com.afternote.feature.receiver.domain.model.SenderEntry +import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +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.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReceivedRecordsViewModelTest { + private val mainDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `저장소가 복원한 발신자 카드를 화면 상태로 노출한다`() = + runTest(mainDispatcher) { + val restored = SenderEntry(id = "sender-id", name = "아버지", masterKey = "master-key") + val viewModel = + ReceivedRecordsViewModel( + SenderRegistry(FakeSenderRegistryRepository(initialSenders = listOf(restored))), + ) + + // WhileSubscribed stateIn의 raw Flow 구독은 StandardTestDispatcher로는 + // backgroundScope에서 즉시 돌지 않으므로 Unconfined로 시작한다. + backgroundScope.launch(mainDispatcher) { viewModel.senders.collect() } + + assertEquals(listOf(restored), viewModel.senders.value) + } +} diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt index 45dcea11e9..e196bfc3a9 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailApprovedAtTest.kt @@ -5,9 +5,10 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceivedRecordBox -import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -146,7 +147,22 @@ class SenderDetailApprovedAtTest { private class Fixture( status: DeliveryVerificationStatus, ) { - val registry = SenderRegistry() + private val senderId = "sender-id" + val registry = + SenderRegistry( + FakeSenderRegistryRepository( + initialSenders = + listOf( + SenderEntry( + id = senderId, + name = "아버지", + masterKey = MY_AUTH_CODE, + realSenderName = "김발신", + relation = "가족", + ), + ), + ), + ) val receiver = FakeReceiverRepository.strict().apply { onSaveMasterKey = {} } val auth = FakeReceiverAuthRepository( @@ -163,23 +179,6 @@ class SenderDetailApprovedAtTest { ) }, ) - private val senderId: String - - init { - val entry = registry.register("아버지") - registry.attachIdentity( - id = entry.id, - masterKey = MY_AUTH_CODE, - identity = - ReceiverIdentity( - receiverId = 1L, - receiverName = "김수신", - senderName = "김발신", - relation = "가족", - ), - ) - senderId = entry.id - } fun viewModel(): SenderDetailViewModel = SenderDetailViewModel( diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt index 1e343b97a8..4dcc50f071 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/senderdetail/SenderDetailViewModelTest.kt @@ -4,9 +4,10 @@ import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus -import com.afternote.feature.receiver.domain.model.ReceiverIdentity +import com.afternote.feature.receiver.domain.model.SenderEntry import com.afternote.feature.receiver.domain.testing.FakeReceiverAuthRepository import com.afternote.feature.receiver.domain.testing.FakeReceiverRepository +import com.afternote.feature.receiver.domain.testing.FakeSenderRegistryRepository import com.afternote.feature.receiver.presentation.recordsbox.SenderRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -120,7 +121,22 @@ class SenderDetailViewModelTest { } private class Fixture { - val registry = SenderRegistry() + val senderId = "sender-id" + val registry = + SenderRegistry( + FakeSenderRegistryRepository( + initialSenders = + listOf( + SenderEntry( + id = senderId, + name = "아버지", + masterKey = "auth-1", + realSenderName = "김발신", + relation = "가족", + ), + ), + ), + ) val statusResults = ArrayDeque>() val receiver = FakeReceiverRepository.strict().apply { @@ -130,23 +146,6 @@ private class Fixture { FakeReceiverAuthRepository( onGetDeliveryVerificationStatus = { statusResults.removeFirst() }, ) - val senderId: String - - init { - val entry = registry.register("아버지") - registry.attachIdentity( - id = entry.id, - masterKey = "auth-1", - identity = - ReceiverIdentity( - receiverId = 1L, - receiverName = "김수신", - senderName = "김발신", - relation = "가족", - ), - ) - senderId = entry.id - } fun viewModel(): SenderDetailViewModel = SenderDetailViewModel(