diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt index 1c0bd3557..8b312b4b9 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt @@ -99,6 +99,10 @@ internal class UserReceiverRepositoryImpl } } + override fun refreshReceiverList() { + receiverRefreshRevision.update { it + 1 } + } + /** * 비로그인 상태에서는 서버를 호출하지 않고 빈 목록을 돌려준다. 따라서 호출처는 빈 목록만으로 * «수신인 없음» 과 «로그인 안 됨» 을 구분할 수 없다 — 구분이 필요하면 [AuthRepository.isLoggedIn] 을 함께 봐야 한다. diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt index db6195268..91d255bf3 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt @@ -98,6 +98,71 @@ class UserRepositoryImplTest { assertEquals(2, requestCount) } + @Test + fun `refreshReceiverList - 같은 구독을 서버의 최신 목록으로 갱신한다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount++ + dataResponse(listOf(receiverDto("조회 $requestCount"))) + }, + ) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + assertEquals("조회 1", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + + val narrowContract: UserReceiverRepository = repository + narrowContract.refreshReceiverList() + + assertEquals("조회 2", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + assertEquals(2, requestCount) + } finally { + collector.cancelAndJoin() + } + } + + @Test + fun `refreshReceiverList - 갱신 실패는 마지막 목록을 유지하고 다음 갱신은 복구한다`() = + runBlocking { + var requestCount = 0 + val repository = + repository( + onGetReceivers = { + requestCount++ + if (requestCount == 2) throw UnknownHostException("일시적인 조회 실패") + dataResponse(listOf(receiverDto("조회 $requestCount"))) + }, + ) + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + repository.receiverListFlow.collect { emissions.send(it) } + } + + try { + val first = withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() } + repository.refreshReceiverList() + + assertEquals(first, withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + assertEquals(2, requestCount) + assertEquals(1, errorReporter.writtenFailures.size) + + repository.refreshReceiverList() + + assertEquals("조회 3", withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }.single().name) + assertEquals(3, requestCount) + } finally { + collector.cancelAndJoin() + } + } + @Test fun `receiverListFlow - 조회가 실패해도 예외로 새지 않고 빈 목록을 낸다`() { val repository = repository(onGetReceivers = { throw UnknownHostException("Unable to resolve host") }) diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt index 5f096bdde..77080cba5 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt @@ -24,6 +24,12 @@ interface UserReceiverRepository { */ val receiverListFlow: Flow> + /** + * 현재 구독 중인 수신자 목록을 다시 조회하도록 요청한다. 조회 결과와 세션별 실패 처리는 + * [receiverListFlow] 계약을 따르며, 새 구독은 이 호출 없이도 최신 목록을 조회한다. + */ + fun refreshReceiverList() + // 수신자 목록 조회 suspend fun getReceivers(): List diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt index e45068b3d..1de30c816 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt @@ -31,6 +31,7 @@ class FakeUserReceiverRepository( var onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? = null, var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? = null, + var onRefreshReceiverList: (() -> Unit)? = null, ) : UserReceiverRepository { val receiverState = MutableStateFlow(receivers.toList()) val receiverDetails = ConcurrentHashMap(receiverDetails) @@ -43,6 +44,7 @@ class FakeUserReceiverRepository( private val receiverListFlowCounter = AtomicInteger() private val getReceiversCounter = AtomicInteger() + private val refreshReceiverListCounter = AtomicInteger() val receiverCreateCalls = CopyOnWriteArrayList() val receiverDetailCalls = CopyOnWriteArrayList() @@ -53,6 +55,7 @@ class FakeUserReceiverRepository( val receiverListFlowCalls: Int get() = receiverListFlowCounter.get() val getReceiversCalls: Int get() = getReceiversCounter.get() + val refreshReceiverListCalls: Int get() = refreshReceiverListCounter.get() val receiverCalls: Int get() = getReceiversCounter.get() override val receiverListFlow: Flow> @@ -61,6 +64,11 @@ class FakeUserReceiverRepository( return onReceiverListFlow?.invoke() ?: receiverState } + override fun refreshReceiverList() { + refreshReceiverListCounter.incrementAndGet() + onRefreshReceiverList?.invoke() + } + override suspend fun getReceivers(): List { getReceiversCounter.incrementAndGet() onGetReceivers?.let { return it() } @@ -179,6 +187,7 @@ class FakeUserReceiverRepository( receivers = emptyList(), onReceiverListFlow = { unexpectedCall("UserReceiverRepository.receiverListFlow") }, onGetReceivers = { unexpectedCall("UserReceiverRepository.getReceivers") }, + onRefreshReceiverList = { unexpectedCall("UserReceiverRepository.refreshReceiverList") }, onCreateReceiver = { _, _, _, _, _ -> unexpectedCall("UserReceiverRepository.createReceiver") }, onGetReceiverDetail = { unexpectedCall("UserReceiverRepository.getReceiverDetail") }, onUpdateReceiver = { _, _, _, _, _ -> unexpectedCall("UserReceiverRepository.updateReceiver") }, diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt index 0bea06fef..d71c0de31 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserRepository.kt @@ -44,6 +44,7 @@ class FakeUserRepository private constructor( onUpdateReceiverDeliveryConditions: ( suspend (Long, List) -> ReceiverDeliveryConditions )? = null, + onRefreshReceiverList: (() -> Unit)? = null, ) : this( receiverFake = FakeUserReceiverRepository( @@ -52,6 +53,7 @@ class FakeUserRepository private constructor( deliveryConditions = deliveryConditions, onReceiverListFlow = onReceiverListFlow, onGetReceivers = onGetReceivers, + onRefreshReceiverList = onRefreshReceiverList, onCreateReceiver = onCreateReceiver, onGetReceiverDetail = onGetReceiverDetail, onUpdateReceiver = onUpdateReceiver, @@ -79,6 +81,7 @@ class FakeUserRepository private constructor( val deliveryUpdateCalls: CopyOnWriteArrayList get() = receiverFake.deliveryUpdateCalls val receiverListFlowCalls: Int get() = receiverFake.receiverListFlowCalls val getReceiversCalls: Int get() = receiverFake.getReceiversCalls + val refreshReceiverListCalls: Int get() = receiverFake.refreshReceiverListCalls val receiverCalls: Int get() = receiverFake.receiverCalls var onReceiverListFlow: (() -> Flow>)? @@ -87,6 +90,12 @@ class FakeUserRepository private constructor( receiverFake.onReceiverListFlow = value } + var onRefreshReceiverList: (() -> Unit)? + get() = receiverFake.onRefreshReceiverList + set(value) { + receiverFake.onRefreshReceiverList = value + } + var onGetReceivers: (suspend () -> List)? get() = receiverFake.onGetReceivers set(value) { @@ -158,6 +167,7 @@ class FakeUserRepository private constructor( receivers = emptyList(), onReceiverListFlow = { unexpectedCall("UserRepository.receiverListFlow") }, onGetReceivers = { unexpectedCall("UserRepository.getReceivers") }, + onRefreshReceiverList = { unexpectedCall("UserRepository.refreshReceiverList") }, onCreateReceiver = { _, _, _, _, _ -> unexpectedCall("UserRepository.createReceiver") }, onGetReceiverDetail = { unexpectedCall("UserRepository.getReceiverDetail") }, onUpdateReceiver = { _, _, _, _, _ -> unexpectedCall("UserRepository.updateReceiver") }, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt index 1d4b63a51..36f8c83e1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModel.kt @@ -14,7 +14,7 @@ import javax.inject.Inject internal class ReceiverListViewModel @Inject constructor( - userRepository: UserReceiverRepository, + private val userRepository: UserReceiverRepository, ) : MviViewModel(ReceiverListUiState()) { private val receivers = userRepository.receiverListFlow private var observationJob: Job? = null @@ -36,8 +36,14 @@ internal class ReceiverListViewModel } private fun startObservation() { + val isReturning = stopJob != null stopJob?.cancel() - if (observationJob?.isActive == true) return + stopJob = null + if (observationJob?.isActive == true) { + // 구독 중단 유예 안에 복귀해도 서버 목록은 갱신한다. 같은 구독의 실패 시 캐시는 보존한다. + if (isReturning) userRepository.refreshReceiverList() + return + } observationJob = viewModelScope.launch { receivers.collect { receivers -> diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt index 41e6be5d1..4bbb8f1cf 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverListViewModelTest.kt @@ -27,6 +27,8 @@ class ReceiverListViewModelTest { private val receivers = MutableStateFlow(listOf(Receiver(7L, "김수신", "가족", "auth-7"))) private var subscriptions = 0 private var cancellations = 0 + private var refreshes = 0 + private var serverReceivers = receivers.value @Before fun setUp() { @@ -53,6 +55,7 @@ class ReceiverListViewModelTest { assertEquals(listOf(ReceiverListItem(7L, "김수신", "가족")), viewModel.uiState.value.receivers) assertEquals(1, subscriptions) + assertEquals(0, refreshes) } @Test @@ -67,26 +70,48 @@ class ReceiverListViewModelTest { assertEquals(1, subscriptions) assertEquals(0, cancellations) + assertEquals(0, refreshes) } @Test - fun `5초 이내 복귀는 기존 구독과 최신 목록을 유지한다`() = + fun `5초 이내 복귀는 기존 구독을 유지하면서 서버 목록을 다시 조회한다`() = runTest(dispatcher) { val viewModel = viewModel() viewModel.onIntent(ReceiverListIntent.ObservationStarted) runCurrent() viewModel.onIntent(ReceiverListIntent.ObservationStopped) advanceTimeBy(4_999) - receivers.value = listOf(Receiver(8L, "박수신", "친구", "auth-8")) + serverReceivers = listOf(Receiver(8L, "박수신", "친구", "auth-8")) runCurrent() - assertEquals(listOf(ReceiverListItem(8L, "박수신", "친구")), viewModel.uiState.value.receivers) + assertEquals(listOf(ReceiverListItem(7L, "김수신", "가족")), viewModel.uiState.value.receivers) viewModel.onIntent(ReceiverListIntent.ObservationStarted) advanceTimeBy(5_000) runCurrent() + assertEquals(listOf(ReceiverListItem(8L, "박수신", "친구")), viewModel.uiState.value.receivers) + assertEquals(1, subscriptions) + assertEquals(0, cancellations) + assertEquals(1, refreshes) + } + + @Test + fun `빠른 재진입마다 한 번 갱신하고 중복 시작 신호는 추가 조회하지 않는다`() = + runTest(dispatcher) { + val viewModel = viewModel() + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + + repeat(2) { + viewModel.onIntent(ReceiverListIntent.ObservationStopped) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + viewModel.onIntent(ReceiverListIntent.ObservationStarted) + runCurrent() + } + assertEquals(1, subscriptions) assertEquals(0, cancellations) + assertEquals(2, refreshes) } @Test @@ -108,6 +133,7 @@ class ReceiverListViewModelTest { runCurrent() assertEquals(2, subscriptions) + assertEquals(0, refreshes) assertEquals(listOf(ReceiverListItem(9L, "이수신", "동료")), viewModel.uiState.value.receivers) } @@ -157,6 +183,10 @@ class ReceiverListViewModelTest { private fun viewModel(): ReceiverListViewModel { val repository = FakeUserReceiverRepository.strict().apply { + onRefreshReceiverList = { + refreshes++ + receivers.value = serverReceivers + } onReceiverListFlow = { flow { subscriptions++