Skip to content
Closed
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 @@ -5,6 +5,7 @@ import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.data.mapper.delivery.toRequestDto
import com.afternote.core.data.mapper.user.toDomain
import com.afternote.core.domain.error.ReceiverRequestRejectedException
import com.afternote.core.domain.model.ReceiverListState
import com.afternote.core.domain.repository.UserReceiverRepository
import com.afternote.core.domain.repository.auth.AuthRepository
import com.afternote.core.model.delivery.DeliveryConditionItem
Expand All @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -55,47 +57,54 @@ internal class UserReceiverRepositoryImpl
) : UserReceiverRepository {
private val receiverRefreshRevision = MutableStateFlow(0L)

// 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은
// 로그인 구간에서 이 collector가 마지막으로 성공한 목록으로 낮춘다. 캐시를 flow 안에 두는 이유는
// 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다.
@OptIn(ExperimentalCoroutinesApi::class)
override val receiverListFlow: Flow<List<Receiver>> =
override val receiverListStateFlow: Flow<ReceiverListState> =
authRepository.isLoggedIn
.distinctUntilChanged()
.flatMapLatest { loggedIn ->
if (!loggedIn) {
flowOf(emptyList())
flowOf(ReceiverListState.SignedOut)
} else {
receiverListForAuthenticatedSession()
}
}

private fun receiverListForAuthenticatedSession(): Flow<List<Receiver>> =
// 기존 소비자는 로딩 중 방출을 받지 않으며 실패 시 같은 구독의 마지막 목록을 받는다.
override val receiverListFlow: Flow<List<Receiver>> =
receiverListStateFlow.mapNotNull { state ->
when (state) {
is ReceiverListState.Loading -> null
is ReceiverListState.Success -> state.receivers
is ReceiverListState.Failure -> state.previousReceivers.orEmpty()
ReceiverListState.SignedOut -> emptyList()
}
}

private fun receiverListForAuthenticatedSession(): Flow<ReceiverListState> =
flow {
var lastKnownReceivers = emptyList<Receiver>()
// 저장소가 아닌 로그인 구간 + collector에 귀속하여 계정 간 캐시 공유를 막는다.
var lastKnownReceivers: List<Receiver>? = null
receiverRefreshRevision.collect {
val receivers =
emit(ReceiverListState.Loading(lastKnownReceivers))
val state =
runCatchingCancellable { getReceivers() }
.onFailure {
// 이 flow 가 하는 일이 «예외를 삼켜 화면을 살리는 것» 이라, 삼킨 뒤의
// 기록이 이 실패 경로의 유일한 신호다. logcat 은 실기에서 회수되지 않으므로
// 크래시 리포팅 창구로 남긴다. 취소 제외·문구 redaction 은 리포터 정책이 담당한다.
errorReporter.recordFailure(
throwable = it,
attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST),
)
}.fold(
onSuccess = { it },
.fold(
onSuccess = { receivers ->
lastKnownReceivers = receivers
ReceiverListState.Success(receivers)
},
onFailure = { failure ->
errorReporter.recordFailure(
throwable = failure,
attributes = mapOf(KEY_STAGE to STAGE_RECEIVER_LIST),
)
if (failure is ApiException && failure.status == UNAUTHORIZED_STATUS) {
emptyList()
} else {
lastKnownReceivers
lastKnownReceivers = null
}
ReceiverListState.Failure(lastKnownReceivers)
},
)
lastKnownReceivers = receivers
emit(receivers)
emit(state)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.afternote.core.data.repoimpl

import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.core.domain.model.ReceiverListState
import com.afternote.core.domain.repository.UserReceiverRepository
import com.afternote.core.domain.repository.UserRepository
import com.afternote.core.domain.testing.FakeAuthRepository
Expand Down Expand Up @@ -28,7 +29,10 @@ import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRe
import com.afternote.core.network.model.ApiException
import com.afternote.core.network.model.BaseResponse
import com.afternote.core.network.service.UserApiService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
Expand All @@ -37,6 +41,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.UnknownHostException

Expand Down Expand Up @@ -464,6 +469,223 @@ class UserRepositoryImplTest {
}
}

@Test
fun `receiverListStateFlow - 로딩 뒤 성공한 빈 목록을 명시한다`() =
runBlocking {
val response = CompletableDeferred<BaseResponse<List<ReceiverListDto>>>()
val repository = repository(onGetReceivers = { response.await() })
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
response.complete(dataResponse(emptyList()))

assertEquals(ReceiverListState.Success(emptyList()), emissions.nextState())
assertTrue(errorReporter.writtenFailures.isEmpty())
} finally {
collector.cancelAndJoin()
}
}

@Test
fun `receiverListStateFlow - 첫 실패는 빈 목록 성공과 구별되고 같은 구독에서 재시도한다`() =
runBlocking {
var requestCount = 0
val repository =
repository(
onGetReceivers = {
requestCount++
if (requestCount == 1) throw UnknownHostException("첫 조회 실패")
dataResponse(listOf(receiverDto("재시도 성공")))
},
)
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
assertEquals(ReceiverListState.Failure(null), emissions.nextState())
assertEquals(1, errorReporter.writtenFailures.size)

val contract: UserReceiverRepository = repository
contract.refreshReceiverList()

assertEquals(ReceiverListState.Loading(null), emissions.nextState())
val recovered = emissions.nextState() as ReceiverListState.Success
assertEquals(listOf("재시도 성공"), recovered.receivers.map { it.name })
assertEquals(2, requestCount)
assertEquals(1, errorReporter.writtenFailures.size)
} finally {
collector.cancelAndJoin()
}
}

@Test
fun `receiverListStateFlow - 갱신 로딩과 실패에 마지막 성공 목록을 보존한다`() =
runBlocking {
var requestCount = 0
val repository =
repository(
onGetReceivers = {
requestCount++
if (requestCount > 1) throw UnknownHostException("갱신 실패")
dataResponse(listOf(receiverDto("기존 수신자")))
},
)
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
val loaded = emissions.nextState() as ReceiverListState.Success
assertEquals(listOf("기존 수신자"), loaded.receivers.map { it.name })

repository.refreshReceiverList()

assertEquals(ReceiverListState.Loading(loaded.receivers), emissions.nextState())
assertEquals(ReceiverListState.Failure(loaded.receivers), emissions.nextState())
assertEquals(2, requestCount)
assertEquals(1, errorReporter.writtenFailures.size)
} finally {
collector.cancelAndJoin()
}
}

@Test
fun `receiverListStateFlow - 401 뒤에는 다음 로딩과 일시 실패에도 폐기한 캐시를 내지 않는다`() =
runBlocking {
var requestCount = 0
val repository =
repository(
onGetReceivers = {
requestCount++
when (requestCount) {
1 -> dataResponse(listOf(receiverDto("권한이 끝난 계정")))
2 -> throw ApiException(401, 401, "인증 만료", "인증 만료")
else -> throw UnknownHostException("401 이후 네트워크 실패")
}
},
)
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
val loaded = emissions.nextState() as ReceiverListState.Success
assertEquals(listOf("권한이 끝난 계정"), loaded.receivers.map { it.name })

repository.refreshReceiverList()

assertEquals(ReceiverListState.Loading(loaded.receivers), emissions.nextState())
assertEquals(ReceiverListState.Failure(null), emissions.nextState())

repository.refreshReceiverList()

assertEquals(ReceiverListState.Loading(null), emissions.nextState())
assertEquals(ReceiverListState.Failure(null), emissions.nextState())
assertEquals(3, requestCount)
assertEquals(2, errorReporter.writtenFailures.size)
} finally {
collector.cancelAndJoin()
}
}

@Test
fun `receiverListStateFlow - 로그아웃과 새 로그인 사이에 이전 계정 캐시를 격리한다`() =
runBlocking {
var requestCount = 0
val authRepository = receiverAuthRepository(loggedIn = true)
val repository =
repository(
authRepository = authRepository,
onGetReceivers = {
requestCount++
if (requestCount > 1) throw UnknownHostException("새 계정 조회 실패")
dataResponse(listOf(receiverDto("이전 계정 수신자")))
},
)
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
val loaded = emissions.nextState() as ReceiverListState.Success
assertEquals(listOf("이전 계정 수신자"), loaded.receivers.map { it.name })

authRepository.loggedIn = false

assertEquals(ReceiverListState.SignedOut, emissions.nextState())
assertEquals(1, requestCount)

authRepository.loggedIn = true

assertEquals(ReceiverListState.Loading(null), emissions.nextState())
assertEquals(ReceiverListState.Failure(null), emissions.nextState())
assertEquals(2, requestCount)
} finally {
collector.cancelAndJoin()
}
}

@Test
fun `receiverListStateFlow - 수집 취소를 API에 전파하고 실패 상태와 리포트를 만들지 않는다`() =
runBlocking {
val requestStarted = CompletableDeferred<Unit>()
val cancelledRequest = CompletableDeferred<CancellationException>()
val repository =
repository(
onGetReceivers = {
requestStarted.complete(Unit)
try {
awaitCancellation()
} catch (cancellation: CancellationException) {
cancelledRequest.complete(cancellation)
throw cancellation
}
},
)
val emissions = Channel<ReceiverListState>(capacity = Channel.UNLIMITED)
val collector =
launch(start = CoroutineStart.UNDISPATCHED) {
repository.receiverListStateFlow.collect { emissions.send(it) }
}

try {
assertEquals(ReceiverListState.Loading(null), emissions.nextState())
withTimeout(TEST_TIMEOUT_MILLIS) { requestStarted.await() }
val cancellation = CancellationException("화면 수집 종료")

collector.cancel(cancellation)
withTimeout(TEST_TIMEOUT_MILLIS) { collector.join() }

val propagated = withTimeout(TEST_TIMEOUT_MILLIS) { cancelledRequest.await() }
assertEquals(cancellation.message, propagated.message)
assertTrue(collector.isCancelled)
assertTrue(emissions.tryReceive().isFailure)
assertTrue(errorReporter.writtenFailures.isEmpty())
} finally {
collector.cancelAndJoin()
}
}

private suspend fun Channel<ReceiverListState>.nextState(): ReceiverListState = withTimeout(TEST_TIMEOUT_MILLIS) { receive() }

private fun receiverAuthRepository(loggedIn: Boolean): FakeAuthRepository =
FakeAuthRepository.strict(loggedIn = loggedIn).apply {
onIsLoggedIn = { loggedInState }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.afternote.core.domain.model

import com.afternote.core.model.user.Receiver

/** 수신자 목록의 조회 결과. 캐시는 로그인 구간과 구독에 귀속된다. */
sealed interface ReceiverListState {
/** null은 이 구독에서 아직 성공한 조회가 없다는 뜻이다. */
data class Loading(
val previousReceivers: List<Receiver>?,
) : ReceiverListState

data class Success(
val receivers: List<Receiver>,
) : ReceiverListState

/** 401은 이전 목록을 폐기한다. 원래 실패는 데이터 계층의 ErrorReporter가 기록한다. */
data class Failure(
val previousReceivers: List<Receiver>?,
) : ReceiverListState

data object SignedOut : ReceiverListState
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.core.domain.repository

import com.afternote.core.domain.model.ReceiverListState
import com.afternote.core.model.delivery.DeliveryConditionItem
import com.afternote.core.model.delivery.ReceiverDeliveryConditions
import com.afternote.core.model.user.Receiver
Expand All @@ -20,10 +21,13 @@ interface UserReceiverRepository {
*
* **빈 목록은 «수신인이 없음» 을 뜻하지 않는다 — 실패도 같은 모양으로 들어온다.** 화면이 이 값만 보고
* «등록된 수신인이 없어요» 를 확정하면 오프라인에서 그 거짓을 사용자에게 보여 주게 된다. 실패와 없음을
* 가르려면 별도 신호가 필요하고, 그 표기는 #714 범위다.
* 가르려면 [receiverListStateFlow]를 사용한다.
*/
val receiverListFlow: Flow<List<Receiver>>

/** 로딩·실패·실제 빈 목록을 구분하며, 기존 목록 전용 구독과 같은 갱신 신호를 사용한다. */
val receiverListStateFlow: Flow<ReceiverListState>

/**
* 현재 구독 중인 수신자 목록을 다시 조회하도록 요청한다. 조회 결과와 세션별 실패 처리는
* [receiverListFlow] 계약을 따르며, 새 구독은 이 호출 없이도 최신 목록을 조회한다.
Expand Down
Loading
Loading