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
6 changes: 6 additions & 0 deletions feature/setting/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ plugins {
dependencies {
// 계정·알림 계약이 노출하는 도메인 모델(UserConnectedAccount·UserPushSetting·UserMarketingConsent).
implementation(projects.core.model)
// 수신자 저장 UseCase 가 core 계약 UserReceiverRepository 를 쓴다 (#1691).
implementation(projects.core.domain)
implementation(libs.coroutines.core)

testFixturesImplementation(projects.core.model)

testImplementation(libs.coroutines.test)
testImplementation(testFixtures(projects.core.domain))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.afternote.feature.setting.domain

import com.afternote.core.domain.repository.UserReceiverRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import javax.inject.Inject

/**
* 수신자 저장 결과 (#1691).
*
* 기본정보와 인사말은 서로 다른 쓰기라 실패한 요청 단계에 따라 사용자에게 알릴 내용을 구분한다.
* 인사말 요청 단계에서 실패하면 기본정보 요청은 이미 성공한 상태다.
*/
public sealed interface UpdateReceiverInfoResult {
public data object Success : UpdateReceiverInfoResult

/**
* 기본정보 쓰기가 실패해 인사말 쓰기를 시도하지 않았다.
*
* [cause] 는 저장소가 던진 원인 그대로다. 소비자가 거절 예외를 가려 문구를 고르므로 감싸지 않는다.
* `Exception` 이 아니라 `Throwable` 인 것은 이 정책을 옮겨 오기 전 ViewModel 이 쓰던
* `runCatchingCancellable` 의 포착 범위(취소를 뺀 모든 `Throwable`)를 그대로 보존한 결과다.
*/
public data class BasicInfoFailed(
public val cause: Throwable,
) : UpdateReceiverInfoResult

public data object MessageFailedAfterBasicInfoUpdated : UpdateReceiverInfoResult
}

/**
* 수신자 기본정보를 저장한 뒤 인사말을 저장한다 (#1691).
*
* 순서를 고정한다. 기본정보가 실패하면 인사말은 시도하지 않는다. 두 쓰기 사이에서 취소되면
* 인사말 쓰기를 시작하지 않고 취소를 그대로 전파한다.
*/
public class UpdateReceiverInfoUseCase
@Inject
constructor(
private val repository: UserReceiverRepository,
) {
public suspend operator fun invoke(
receiverId: Long,
name: String,
phone: String,
relation: String,
email: String,
message: String,
): UpdateReceiverInfoResult {
try {
repository.updateReceiver(receiverId, name, phone, relation, email)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Throwable) {
return UpdateReceiverInfoResult.BasicInfoFailed(failure)
}
currentCoroutineContext().ensureActive()
return try {
repository.updateReceiverMessage(receiverId, message)
UpdateReceiverInfoResult.Success
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Throwable) {
UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.afternote.feature.setting.domain

import com.afternote.core.domain.repository.UserReceiverRepository
import com.afternote.core.model.delivery.ConditionState
import com.afternote.core.model.delivery.DeliveryConditionItem
import com.afternote.core.model.delivery.DeliveryConditionType
import com.afternote.core.model.delivery.DeliveryContentType
import com.afternote.core.model.delivery.InactivityPeriod
import com.afternote.core.model.delivery.ReceiverDeliveryConditions
import javax.inject.Inject

/**
* 타임레터 전달조건만 바꿔 수신자의 전달조건 전량을 저장한다 (#1691).
*
* 저장 API 가 조건 목록을 통째로 받으므로 다른 콘텐츠의 조건은 손대지 않고 그대로 실어 보낸다.
* 타임레터 조건이 아직 없으면 ACTIVE 로 합성하고, 수신자 요청 방식에는 무활동 기간을 비운다.
*
* 실패는 호출부가 판정한다. 이 UseCase 는 예외를 삼키지 않고 저장소 응답을 그대로 돌려준다.
*/
public class UpdateTimeLetterDeliveryConditionUseCase
@Inject
constructor(
private val repository: UserReceiverRepository,
) {
public suspend operator fun invoke(
receiverId: Long,
conditions: List<DeliveryConditionItem>,
conditionType: DeliveryConditionType,
inactivityPeriod: InactivityPeriod,
): ReceiverDeliveryConditions {
val period = inactivityPeriod.takeIf { conditionType == DeliveryConditionType.INACTIVITY }
val updated =
conditions
.map { condition ->
if (condition.contentType == DeliveryContentType.TIME_LETTER) {
condition.copy(conditionType = conditionType, inactivityPeriod = period)
} else {
condition
}
}.let { items ->
if (items.any { it.contentType == DeliveryContentType.TIME_LETTER }) {
items
} else {
items +
DeliveryConditionItem(
contentType = DeliveryContentType.TIME_LETTER,
conditionType = conditionType,
inactivityPeriod = period,
state = ConditionState.ACTIVE,
fulfilled = false,
gracePeriodStartedAt = null,
fulfilledAt = null,
)
}
}
return repository.updateReceiverDeliveryConditions(receiverId, updated)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.afternote.feature.setting.domain

import com.afternote.core.domain.testing.FakeUserReceiverRepository
import com.afternote.core.model.delivery.ConditionState
import com.afternote.core.model.delivery.DeliveryConditionItem
import com.afternote.core.model.delivery.DeliveryConditionType
import com.afternote.core.model.delivery.DeliveryContentType
import com.afternote.core.model.delivery.InactivityPeriod
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test

class ReceiverSavePolicyTest {
@Test
fun `basic info failure skips message and preserves cause`() =
runTest {
val error = IllegalStateException("save failed")
val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error })
val result = save(repository)
assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause)
assertTrue(repository.receiverMessageCalls.isEmpty())
}

/**
* 옮겨 오기 전 ViewModel 은 `runCatchingCancellable` 로 감싸 `Exception` 이 아닌 `Throwable` 도
* 화면 오류로 바꿨다. UseCase 가 `Exception` 만 잡으면 그 경로가 코루틴 밖으로 튀어 화면이 저장 중에
* 머문다 — 포착 범위를 좁히는 회귀를 여기서 막는다.
*/
@Test
fun `basic info failure keeps non exception throwable as cause`() =
runTest {
val error = AssertionError("save failed")
val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw error })
val result = save(repository)
assertSame(error, (result as UpdateReceiverInfoResult.BasicInfoFailed).cause)
assertTrue(repository.receiverMessageCalls.isEmpty())
}

@Test
fun `message failure reports already saved basic info`() =
runTest {
val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> error("message failed") })
assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository))
assertEquals(
"updated",
repository.receiverState.value
.single()
.name,
)
assertEquals(1, repository.receiverMessageCalls.size)
}

/** 인사말 쪽도 같은 이유로 `Throwable` 까지 부분 저장 결과로 접는다. */
@Test
fun `message failure reports partial save for non exception throwable`() =
runTest {
val repository =
FakeUserReceiverRepository(
onUpdateReceiverMessage = { _, _ -> throw AssertionError("message failed") },
)
assertEquals(UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated, save(repository))
assertEquals(1, repository.receiverMessageCalls.size)
}

@Test
fun `success saves basic info before message`() =
runTest {
val repository = FakeUserReceiverRepository()
repository.onUpdateReceiverMessage = { _, _ ->
assertEquals(
"updated",
repository.receiverState.value
.single()
.name,
)
}
assertEquals(UpdateReceiverInfoResult.Success, save(repository))
}

@Test
fun `cancellation between writes does not save message`() =
runTest {
val repository = FakeUserReceiverRepository()
repository.onUpdateReceiver = { _, _, _, _, _ ->
currentCoroutineContext().cancel()
repository.receiverState.value.single()
}
var completed = false
val job =
launch {
save(repository)
completed = true
}
job.join()
assertTrue(job.isCancelled)
assertTrue(repository.receiverMessageCalls.isEmpty())
assertEquals(false, completed)
}

@Test
fun `basic info cancellation propagates`() =
runTest {
val cancelled = CancellationException("cancelled")
val repository = FakeUserReceiverRepository(onUpdateReceiver = { _, _, _, _, _ -> throw cancelled })
try {
save(repository)
error("Expected cancellation")
} catch (actual: CancellationException) {
assertSame(cancelled, actual)
}
assertTrue(repository.receiverMessageCalls.isEmpty())
}

@Test
fun `message cancellation propagates`() =
runTest {
val cancelled = CancellationException("cancelled")
val repository = FakeUserReceiverRepository(onUpdateReceiverMessage = { _, _ -> throw cancelled })
try {
save(repository)
error("Expected cancellation")
} catch (actual: CancellationException) {
assertSame(cancelled, actual)
}
}

@Test
fun `time letter patch preserves other conditions and removes request period`() =
runTest {
val otherType = DeliveryContentType.entries.first { it != DeliveryContentType.TIME_LETTER }
val other = condition(otherType)
val timeLetter = condition(DeliveryContentType.TIME_LETTER)
val repository = FakeUserReceiverRepository()
val result =
UpdateTimeLetterDeliveryConditionUseCase(repository)(
7L,
listOf(other, timeLetter),
DeliveryConditionType.RECEIVER_REQUEST,
InactivityPeriod.ONE_YEAR,
)
assertEquals(
listOf(other, timeLetter.copy(conditionType = DeliveryConditionType.RECEIVER_REQUEST, inactivityPeriod = null)),
result.conditions,
)
assertEquals(result.conditions, repository.deliveryUpdateCalls.single().conditions)
}

@Test
fun `missing time letter is synthesized with selected inactivity period`() =
runTest {
val repository = FakeUserReceiverRepository()
val result =
UpdateTimeLetterDeliveryConditionUseCase(repository)(
7L,
emptyList(),
DeliveryConditionType.INACTIVITY,
InactivityPeriod.ONE_YEAR,
)
assertEquals(listOf(condition(DeliveryContentType.TIME_LETTER)), result.conditions)
}

private suspend fun save(repository: FakeUserReceiverRepository): UpdateReceiverInfoResult =
UpdateReceiverInfoUseCase(repository)(7L, "updated", "01012345678", "가족", "receiver@test.com", "hello")

private fun condition(type: DeliveryContentType) =
DeliveryConditionItem(
contentType = type,
conditionType = DeliveryConditionType.INACTIVITY,
inactivityPeriod = InactivityPeriod.ONE_YEAR,
state = ConditionState.ACTIVE,
fulfilled = false,
gracePeriodStartedAt = null,
fulfilledAt = null,
)
}
Loading
Loading