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
6 changes: 5 additions & 1 deletion .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ name: Unit Test
# - :koverXmlReportCi / :koverHtmlReportCi : Android debug + 순수 JVM 테스트와 aggregate coverage
# - :konsist:test : 순수 JVM 모듈(testDebugUnitTest 미보유)이라 별도 명시. 레이어 의존 아키텍처 가드.
# - :build-logic:test : included build 라 루트 태스크에 안 묶여 별도 명시. release 키 가드(TestKit) 회귀 가드.
# - :app:compileDebugAndroidTestKotlin : androidTest 실행은 정기 워크플로(android-managed-device.yml)
# 뿐이라 PR 단계엔 androidTest 컴파일 검증이 없었다. 그 공백으로 #861/#867 androidTest 배치가
# develop 의 시그니처 변경(#868)과 조합 컴파일이 깨진 채 머지됨 — 실행은 못 해도 컴파일은 여기서 잡는다.
# androidTest 소스셋은 :app 에만 있다.
# lint.yml 과 setup 이 거의 동일하지만 분리 — lint 실패가 test 결과를 가리지 않도록 fail-fast 독립.

# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에
Expand Down Expand Up @@ -52,7 +56,7 @@ jobs:
uses: ./.github/actions/setup-ci-config

- name: Run unit tests and aggregate Kover reports
run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test --continue --parallel
run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin --continue --parallel

- name: Upload Kover coverage reports
id: kover-artifact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ class AfternoteAuthorExtendedAndroidTest {
Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow
repository.listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty())
val viewModel = AfternoteHomeViewModel(repository)
val accountRoutes = mutableListOf<String>()
val galleryRoutes = mutableListOf<String>()
val memorialRoutes = mutableListOf<String>()
val accountRoutes = mutableListOf<Long>()
val galleryRoutes = mutableListOf<Long>()
val memorialRoutes = mutableListOf<Long>()
val addRoutes = mutableListOf<AfternoteType?>()

composeRule.setContent {
Expand Down Expand Up @@ -157,9 +157,9 @@ class AfternoteAuthorExtendedAndroidTest {
.onNodeWithContentDescription("추억 노트")
.performScrollTo()
.performClick()
assertEquals(listOf("101"), accountRoutes)
assertEquals(listOf("102"), galleryRoutes)
assertEquals(listOf("103"), memorialRoutes)
assertEquals(listOf(101L), accountRoutes)
assertEquals(listOf(102L), galleryRoutes)
assertEquals(listOf(103L), memorialRoutes)

composeRule.onNodeWithText("소셜네트워크").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
Expand Down Expand Up @@ -602,19 +602,19 @@ private fun editorViewModel(
private fun authorListItems(): List<ListItem> =
listOf(
ListItem(
id = "101",
id = 101L,
serviceName = "Instagram",
date = "2026.08.22",
type = AfternoteType.SOCIAL_NETWORK,
),
ListItem(
id = "102",
id = 102L,
serviceName = "Google Drive",
date = "2026.08.22",
type = AfternoteType.GALLERY_AND_FILES,
),
ListItem(
id = "103",
id = 103L,
serviceName = "추억 노트",
date = "2026.08.22",
type = AfternoteType.MEMORIAL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException
import com.afternote.feature.afternote.domain.model.LeaveMessageBlock
import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem
import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult
import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult
import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail
import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle
import com.afternote.feature.afternote.domain.model.receiver.ReceivedPlaylistDetail
Expand All @@ -70,6 +69,8 @@ import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfte
import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel
import com.afternote.feature.afternote.presentation.receiver.navigation.ReceiverNavActions
import com.afternote.feature.afternote.presentation.receiver.navigation.model.ReceiverRoute
import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords
import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl
Expand All @@ -79,6 +80,9 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo
import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository
import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository
import com.afternote.feature.receiver.domain.repository.ReceiverDeliveryDocumentUploadRepository
import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter
import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList
import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CompletableDeferred
Expand Down Expand Up @@ -231,7 +235,13 @@ class ReceiverRuntimeCompletionAndroidTest {
val allFailureAttempt = repository.enqueueHomeAttempt()
val partialAttempt = repository.enqueueHomeAttempt()
val reporter = FakeErrorReporter()
val viewModel = ReceiverHomeViewModel(repository, reporter)
val viewModel =
ReceiverHomeViewModel(
receiverRepository = repository,
mindRecordReceiverRepository = repository.mindRecordReceiverRepository,
receiverTimeLetterRepository = repository.receiverTimeLetterRepository,
errorReporter = reporter,
)

composeRule.setContent {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
Expand Down Expand Up @@ -281,7 +291,7 @@ class ReceiverRuntimeCompletionAndroidTest {
),
),
mindRecords = Result.failure(IllegalStateException("mind records unavailable")),
timeLetters = Result.success(LoadCountResult(totalCount = 8)),
timeLetters = Result.success(ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 8)),
senderMessage =
Result.success(
SenderMessageInfo(
Expand All @@ -296,8 +306,9 @@ class ReceiverRuntimeCompletionAndroidTest {
.onNodeWithText(context.getString(R.string.receiver_home_sender_record_title, "이발신"))
.assertIsDisplayed()
composeRule.onNodeWithText("언제나 응원할게").assertIsDisplayed()
// 마음의 기록 실패 섹션은 가짜 0 대신 조회 실패 문구로 구분된다.
composeRule
.onNodeWithText("0개 마음의 기록이 있습니다.")
.onNodeWithText(context.getString(R.string.receiver_home_section_count_unavailable))
.assertIsDisplayed()
composeRule
.onNodeWithText("2개의 애프터노트가 있습니다.")
Expand Down Expand Up @@ -629,7 +640,7 @@ class ReceiverRuntimeCompletionAndroidTest {

composeRule.setContent {
AfternoteTheme {
ReceivedAfternoteDetailRoute(onBack = {}, viewModel = viewModel)
ReceivedAfternoteDetailRoute(onBack = {}, onNavigateToPlaylist = {}, viewModel = viewModel)
}
}

Expand Down Expand Up @@ -668,7 +679,7 @@ class ReceiverRuntimeCompletionAndroidTest {
receiverRepository = repository,
errorReporter = FakeErrorReporter(),
)
val playlistRoutes = mutableListOf<String>()
val playlistRoutes = mutableListOf<Long>()

composeRule.setContent {
AfternoteTheme {
Expand All @@ -689,21 +700,21 @@ class ReceiverRuntimeCompletionAndroidTest {
.onNode(hasText("추억 플레이리스트") and hasClickAction())
.performClick()

assertEquals(listOf("303"), playlistRoutes)
assertEquals(listOf(303L), playlistRoutes)
assertEquals(listOf(303L), repository.detailIds)
}
}

private data class PendingHomeAttempt(
val afterNotes: CompletableDeferred<Result<AfterNotesListResult>>,
val mindRecords: CompletableDeferred<Result<LoadCountResult>>,
val timeLetters: CompletableDeferred<Result<LoadCountResult>>,
val mindRecords: CompletableDeferred<Result<ReceiverMindRecords>>,
val timeLetters: CompletableDeferred<Result<ReceivedTimeLetterList>>,
val senderMessage: CompletableDeferred<Result<SenderMessageInfo?>>,
) {
fun complete(
afterNotes: Result<AfterNotesListResult>,
mindRecords: Result<LoadCountResult>,
timeLetters: Result<LoadCountResult>,
mindRecords: Result<ReceiverMindRecords>,
timeLetters: Result<ReceivedTimeLetterList>,
senderMessage: Result<SenderMessageInfo?>,
) {
this.afterNotes.complete(afterNotes)
Expand All @@ -718,8 +729,8 @@ private class CompletionReceiverRepository : ReceiverRepository {
override val authCodeFlow: Flow<String?> = authCode

private val afterNoteHomeResults = ArrayDeque<CompletableDeferred<Result<AfterNotesListResult>>>()
private val mindRecordHomeResults = ArrayDeque<CompletableDeferred<Result<LoadCountResult>>>()
private val timeLetterHomeResults = ArrayDeque<CompletableDeferred<Result<LoadCountResult>>>()
private val mindRecordHomeResults = ArrayDeque<CompletableDeferred<Result<ReceiverMindRecords>>>()
private val timeLetterHomeResults = ArrayDeque<CompletableDeferred<Result<ReceivedTimeLetterList>>>()
private val senderMessageHomeResults = ArrayDeque<CompletableDeferred<Result<SenderMessageInfo?>>>()

val detailResults = ArrayDeque<Result<ReceivedAfternoteDetail>>()
Expand Down Expand Up @@ -771,20 +782,31 @@ private class CompletionReceiverRepository : ReceiverRepository {
error("unexpected saveReceivedExportToFile")
}

override suspend fun loadMindRecordsCount(): Result<LoadCountResult> {
homeCallCounts[1] += 1
return mindRecordHomeResults.removeFirst().await()
}

override suspend fun loadTimeLettersCount(): Result<LoadCountResult> {
homeCallCounts[2] += 1
return timeLetterHomeResults.removeFirst().await()
}

override suspend fun loadSenderMessage(): Result<SenderMessageInfo?> {
homeCallCounts[3] += 1
return senderMessageHomeResults.removeFirst().await()
}

/** 홈의 마음의 기록 카운트 — 옛 loadMindRecordsCount 가 분리돼 나간 현행 repository 자리. */
val mindRecordReceiverRepository =
object : MindRecordReceiverRepository {
override suspend fun getAll(): Result<ReceiverMindRecords> {
homeCallCounts[1] += 1
return mindRecordHomeResults.removeFirst().await()
}
}

/** 홈의 타임레터 카운트 — 현행 인터페이스는 Result 대신 예외로 실패를 알린다. */
val receiverTimeLetterRepository =
object : ReceiverTimeLetterRepository {
override suspend fun getReceivedTimeLetters(): ReceivedTimeLetterList {
homeCallCounts[2] += 1
return timeLetterHomeResults.removeFirst().await().getOrThrow()
}

override suspend fun getReceivedTimeLetterDetail(timeLetterReceiverId: Long): ReceivedTimeLetter =
error("unexpected getReceivedTimeLetterDetail")
}
}

private class CompletionIdentityVerificationRepository : IdentityVerificationRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.afternote.domain.model.LeaveMessageBlock
import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem
import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult
import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult
import com.afternote.feature.afternote.domain.model.receiver.ReceivedAccountCredentials
import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail
import com.afternote.feature.afternote.domain.model.receiver.ReceivedExportBundle
Expand Down Expand Up @@ -188,6 +187,7 @@ class ReceiverAdvancedAndroidTest {
AfternoteTheme {
ReceivedAfternoteDetailRoute(
onBack = {},
onNavigateToPlaylist = {},
viewModel = viewModel,
)
}
Expand Down Expand Up @@ -319,10 +319,6 @@ private class AdvancedReceiverRepository : ReceiverRepository {
return saveResults.removeFirst()
}

override suspend fun loadMindRecordsCount(): Result<LoadCountResult> = Result.success(LoadCountResult(totalCount = 0))

override suspend fun loadTimeLettersCount(): Result<LoadCountResult> = Result.success(LoadCountResult(totalCount = 0))

override suspend fun loadSenderMessage(): Result<SenderMessageInfo?> = Result.success(null)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.afternote.feature.setting.presentation.viewmodel.ProfileEditViewModel
import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel
import com.afternote.feature.setting.presentation.viewmodel.SettingUiState
import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel
import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -316,7 +317,7 @@ class SettingAccountSecurityAndroidTest {
}

@Test
fun withdrawFailure_requiresFinalConfirmationAndKeepsSession() {
fun withdrawFailure_showsFailurePopupAndKeepsSession() {
val authRepository = SettingContractAuthRepository()
val userRepository =
SettingContractUserRepository().apply {
Expand All @@ -341,15 +342,12 @@ class SettingAccountSecurityAndroidTest {

composeRule.onNodeWithText("탈퇴하겠습니다").performTextInput("탈퇴하겠습니다")
composeRule.onNodeWithText("탈퇴하기").performClick()
composeRule.onNodeWithText("회원 탈퇴가 완료되었습니다.\n애프터노트를 이용해 주셔서 감사합니다.").assertIsDisplayed()
assertEquals(0, userRepository.deleteAccountCalls)

composeRule.onNodeWithText("확인하기").performClick()
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
userRepository.deleteAccountCalls == 1
}
composeRule.onNodeWithText("회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.").assertIsDisplayed()

assertFalse(viewModel.withdrawCompleted.value)
assertEquals(WithdrawUiState.Error, viewModel.withdrawUiState.value)
assertEquals(0, successCalls)
assertEquals(0, authRepository.clearSessionCalls)
assertTrue(runBlocking { authRepository.isLoggedIn.first() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEven
import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel
import com.afternote.feature.setting.presentation.viewmodel.SettingUiState
import com.afternote.feature.setting.presentation.viewmodel.SettingViewModel
import com.afternote.feature.setting.presentation.viewmodel.WithdrawUiState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -514,9 +515,9 @@ class SettingCompletionAndroidTest {
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
userApi.completedDeleteCalls == 1
}
composeRule.waitForIdle()

assertFalse(viewModel.withdrawCompleted.value)
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
viewModel.withdrawUiState.value == WithdrawUiState.Error
}
assertEquals(0, authRepository.clearSessionCalls)
assertTrue(runBlocking { authRepository.isLoggedIn.first() })

Expand All @@ -528,7 +529,7 @@ class SettingCompletionAndroidTest {

retryGate.complete(BaseResponse(status = 200, code = 200))
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
viewModel.withdrawCompleted.value
viewModel.withdrawUiState.value == WithdrawUiState.Success
}

assertEquals(2, userApi.completedDeleteCalls)
Expand Down
Loading