From 7332a1c31a300adf05b32b2796636f9f9b45567b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 3 Sep 2026 18:43:12 +0900 Subject: [PATCH 001/116] =?UTF-8?q?fix(afternote):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=84=B8=EC=8A=A4=20=EC=82=AC=EB=A7=9D=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=EB=92=A4=20=ED=94=84=EB=A6=AC=ED=95=84=EC=9D=B4=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=8E=B8=EC=A7=91=EC=9D=84=20=EB=8D=AE?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20(#1732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에디터가 프로세스 사망에서 되살아나면 두 갈래가 겹쳤다 — 폼 스냅샷이 사용자의 미저장 편집을 되살리고, 곧이어 `init` 의 상세 재조회가 폼이 더러운지 묻지 않고 `pendingPrefill` 을 다시 발행해 화면이 그것을 실으면서 되살린 편집을 서버 값으로 덮었다. 「덮지 않는다」로 정한다. 서버 값으로 되돌릴지 묻는 화면은 시안도 문구도 없고, 사용자가 쓴 것을 조용히 버리는 쪽이 더 나쁘다. 재조회 자체는 그대로 돈다 — 기준 스냅샷(#1617)이 없으면 저장이 막히고 카테고리도 서버가 아는 값이어야 한다. 막는 것은 「폼에 덮어쓰기」 하나이고, 프리필을 싣지 않은 경로에서는 화면의 통보가 오지 않으므로 skeleton 을 재조회 쪽에서 직접 걷는다. 가드가 보는 것은 폼 스냅샷과 「프리필이 실렸다」 표식이지 값 비교가 아니다. 계정 정보와 남기실 말씀은 화면이 소유해 폼에 없으므로, 값으로 갈랐다면 비밀번호만 고친 복원이 「폼이 같다」로 읽혀 그대로 덮인다. 표식만 보지도 않는다 — 스냅샷 저장은 용량 초과를 삼키므로, 표식만 남은 빈 폼에서 프리필까지 막으면 그 빈 폼이 기준과 짝지어져 「전부 지움」 저장이 된다(#705·#1617 이 막은 경로다). 그래서 프리필이 실린 적 없는 복원은 종전대로 프리필을 받는다. --- .../editor/AfternoteEditorViewModel.kt | 42 +++++- .../AfternoteEditorProcessDeathPrefillTest.kt | 128 ++++++++++++++---- 2 files changed, 144 insertions(+), 26 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 2949abd349..b421139dc5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -60,6 +60,7 @@ import javax.inject.Inject private const val EDITOR_FORM_SNAPSHOT_KEY = "editor_form_snapshot_v4" private const val INITIALIZED_ACTION_TEMPLATE_TYPE_KEY = "initialized_action_template_type" +private const val PREFILL_SEEDED_ITEM_ID_KEY = "editor_prefill_seeded_item_id" private const val TAG = "AfternoteEditorViewModel" @@ -181,6 +182,27 @@ class AfternoteEditorViewModel encodeDefaults = true } + /** + * 이 ViewModel 이 «상세 프리필이 이미 실렸던» 폼 스냅샷에서 되살아났는가 (#1732). + * + * 참이면 복원된 폼은 서버 값 + 사용자가 그 뒤에 고친 것을 함께 들고 있다 — 프로세스 사망 + * 복원은 [EDITOR_FORM_SNAPSHOT_KEY] 의 폼뿐 아니라 화면이 가진 계정 정보·남기실 말씀 + * 입력까지 (`rememberTextFieldState`·`rememberSaveable`) 같은 번들로 되살리기 때문이다. + * 그 위에 [loadExistingAfternoteForEdit] 의 재조회 프리필을 다시 실으면 남는 건 서버 값뿐이라, + * 사용자가 쓴 편집이 아무 안내 없이 사라진다. + * + * 두 조건을 함께 본다. 표식만으로는 부족하다 — [persistFormSnapshot] 은 번들 용량 초과 같은 + * 실패를 삼키므로, 표식은 남았는데 폼 스냅샷이 없는 조합이 가능하다. 그때 프리필까지 막으면 + * 빈 폼이 기준 스냅샷과 짝지어져 「전부 지움」 저장이 된다 (#705·#1617 이 막은 그 경로다). + * + * 폼과 프리필의 값 비교로 대신하지 않는다. 계정 정보·남기실 말씀은 화면이 소유해 이 폼에 + * 없으므로, 비밀번호만 고친 복원은 «폼이 같다» 로 읽혀 그 편집이 그대로 덮인다. + */ + private val restoredFromSeededSnapshot: Boolean = + route.itemId != null && + savedStateHandle.contains(EDITOR_FORM_SNAPSHOT_KEY) && + savedStateHandle.get(PREFILL_SEEDED_ITEM_ID_KEY) == route.itemId + private val internalState = MutableStateFlow( InternalState( @@ -263,7 +285,16 @@ class AfternoteEditorViewModel fun replaceReceiversIfEmpty(receivers: List) = mutateForm { it.withReceiversReplacedIfEmpty(receivers) } - fun applyPrefill(prefill: EditorFormPrefill) = mutateForm { it.withPrefillApplied(prefill) } + /** + * 상세 프리필을 폼에 싣는다. 화면이 계정 정보·남기실 말씀까지 실은 뒤 [onPrefillConsumed] 로 통보한다. + * + * 폼 스냅샷과 같은 번들에 «프리필이 실렸다» 표식을 남긴다 — 프로세스 사망 뒤 재조회가 + * 이 표식을 보고 복원된 편집을 덮지 않는다 ([restoredFromSeededSnapshot], #1732). + */ + fun applyPrefill(prefill: EditorFormPrefill) { + readEditItemId()?.let { savedStateHandle[PREFILL_SEEDED_ITEM_ID_KEY] = it } + mutateForm { it.withPrefillApplied(prefill) } + } /** * 신규 작성 화면의 카테고리 추천 처리 방법을 최초 한 번만 채운다. @@ -642,10 +673,17 @@ class AfternoteEditorViewModel // skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [onPrefillConsumed] 로 통보한다 // (uiState 갱신 시점에 prefill 도착했어도 UI 가 form·TextFieldState 에 반영하기 전이라 // 여기서 끄면 skeleton 사라짐 → 빈 폼 → prefill 깜빡임 발생). + // + // 복원된 편집이 있으면 프리필을 싣지 않는다 (#1732). 조회 자체는 그대로 돈다 — + // 기준 스냅샷([InternalState.updateBaseline])이 없으면 저장이 막히고, 카테고리도 + // 서버가 아는 값이어야 한다. 막는 것은 «폼에 덮어쓰기» 하나다. internalState.update { it.copy( originalType = prefill.type, - pendingPrefill = prefill, + pendingPrefill = if (restoredFromSeededSnapshot) null else prefill, + // 프리필을 싣지 않으면 UI 의 [onPrefillConsumed] 도 오지 않는다 — + // 그 경로에서는 skeleton 을 여기서 직접 걷는다. + isPrefillLoading = it.isPrefillLoading && !restoredFromSeededSnapshot, // 화면에 뿌릴 prefill 과 별개로, 가공 전 원본을 저장 때 견줄 기준으로 남긴다. // 이 값은 폼 변경을 따라가지 않는다 — 따라가면 비교할 대상이 사라진다 (#1617). updateBaseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 2d8ec54eb2..667758a65a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnai import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -22,7 +23,9 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -30,20 +33,19 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * **알려진 한계를 드러내는 가드** — 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮는다. + * 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮지 않는지 고정한다 (#1732). * * 복원 경로는 두 갈래가 겹친다: `SavedStateHandle` 의 폼 스냅샷이 편집분을 되살리고, 곧이어 - * `init` 의 상세 재조회가 `pendingPrefill` 을 다시 발행해 UI 가 폼을 서버 값으로 덮어쓴다. - * 이 테스트는 그 두 번째 발행이 **폼이 더러운지 묻지 않고** 일어난다는 사실을 고정한다. + * `init` 의 상세 재조회가 끝난다. 종전에는 그 재조회가 **폼이 더러운지 묻지 않고** `pendingPrefill` + * 을 다시 발행해, 화면이 그것을 폼·`TextFieldState` 에 실으면서 되살린 편집을 서버 값으로 덮었다. * - * 이 축은 #1617 의 부분 PATCH 가 만든 것이 아니고 고치지도 않는다 — 스냅샷 복원과 프리필 재적용이 - * 함께 있던 종전부터의 동작이다. 부분 PATCH 는 오히려 이 경로를 **덜 위험하게** 만든다: 덮인 폼은 - * 서버 값과 같아져 「바뀐 것 없음」으로 아무 슬롯도 실리지 않고, 프리필이 도착하기 전 저장을 - * 시도하면 기준이 없다는 이유로 [AfternoteEditorMissingBaselineTest] 의 가드가 막는다. + * 결정은 「덮지 않는다」다. 서버 값으로 되돌릴지 묻는 화면은 시안도 문구도 없고, 사용자가 쓴 것을 + * 조용히 버리는 쪽이 더 나쁘다. 재조회 자체는 그대로 돈다 — 기준 스냅샷(#1617)과 카테고리는 + * 서버가 아는 값이어야 하고, 막는 것은 「폼에 덮어쓰기」 하나다. * - * 제대로 고치려면 「복원된 편집이 있으면 프리필을 덮어쓰지 않거나 사용자에게 고를 기회를 준다」는 - * 화면 흐름 결정이 필요하다 — 편집 유실 안내·충돌 해소 UI 는 #1617 의 해야 할 일 2번(서버 거절 시 - * 화면 동작)과 같은 결의 별도 축이라 그쪽에서 다룬다. + * 가드가 보는 것은 **폼 스냅샷 + 「프리필이 실렸다」 표식**이지 폼 값의 비교가 아니다. 계정 정보와 + * 남기실 말씀은 화면(`rememberTextFieldState`·`rememberSaveable`)이 소유해 폼에 없으므로, 값 비교로 + * 갈랐다면 비밀번호만 고친 복원이 「폼이 같다」로 읽혀 그대로 덮였을 것이다. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -62,7 +64,7 @@ class AfternoteEditorProcessDeathPrefillTest { } @Test - fun `복원된 편집이 있어도 상세 재조회 프리필이 다시 발행된다`() = + fun `복원된 편집이 있으면 상세 재조회가 프리필을 발행하지 않는다`() = runTest(dispatcher) { val repository = FakeAfternoteRepository.strict().apply { @@ -72,13 +74,81 @@ class AfternoteEditorProcessDeathPrefillTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - // 복원 직후의 폼은 사용자가 고친 제목을 들고 있다. - assertEquals(EDITED_SERVICE, viewModel.currentForm().selectedService) + assertEquals( + "복원된 폼은 사용자가 고친 제목을 그대로 들고 있다", + EDITED_SERVICE, + viewModel.currentForm().selectedService, + ) + assertNull( + "재조회가 프리필을 발행하면 화면이 그것을 실어 사용자의 편집이 사라진다", + viewModel.uiState.value.pendingPrefill, + ) + } + + /** + * 프리필을 싣지 않으면 화면의 `onPrefillConsumed` 통보도 오지 않는다. skeleton 을 걷고 기준 + * 스냅샷을 세우는 일은 그래서 재조회 쪽이 직접 해야 한다 — 안 그러면 복원된 사용자는 「곧 + * 도착합니다」(`PrefillNotReady`) 에 막혀 영영 저장하지 못한다. + */ + @Test + fun `프리필을 막아도 복원된 편집 그대로 저장이 나간다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + assertFalse( + "프리필을 싣지 않았으니 skeleton 도 여기서 걷혀야 한다", + viewModel.uiState.value.isPrefillLoading, + ) + + // 화면이 복원된 폼 값을 그대로 담아 보내는 저장이다. + viewModel.saveAfternote( + payload = + RegisterAfternotePayload( + serviceName = EDITED_SERVICE, + date = "2026-08-30", + processingMethods = listOf(SERVER_PROCESSING_METHOD), + ), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ) + runCurrent() + + val updated = repository.updateCalls.single().second + assertEquals("사용자가 고친 제목이 그대로 나간다", EDITED_SERVICE, updated.title) + assertNull( + "복원된 폼이 서버 값을 그대로 들고 있으므로 안 건드린 필드는 실리지 않는다 (#1617)", + updated.processingMethods, + ) + } + + /** + * 프리필이 실리기 전에 죽은 복원은 **막지 않는다**. + * + * 그 폼은 사용자가 skeleton 위에서 친 몇 글자뿐이고 서버 값을 본 적이 없다. 여기서 프리필까지 + * 막으면 반쪽짜리 폼이 기준 스냅샷과 짝지어져, 안 건드린 필드가 「전부 지움」으로 나간다 — + * #705·#1617 이 막은 바로 그 사고다. + */ + @Test + fun `프리필이 실린 적 없는 복원은 여전히 프리필을 받는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository, prefillSeeded = false) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() val prefill = viewModel.uiState.value.pendingPrefill - assertNotNull("상세 재조회는 폼이 더러운지 묻지 않고 프리필을 발행한다", prefill) + assertNotNull("서버 값을 본 적 없는 폼은 프리필로 채워야 한다", prefill) assertEquals( - "그 프리필은 서버 값이라, UI 가 적용하면 사용자의 편집이 사라진다", SERVER_SERVICE, (prefill?.content as EditorContentPrefill.Gallery).serviceName, ) @@ -91,25 +161,34 @@ class AfternoteEditorProcessDeathPrefillTest { timestamps = DetailTimestamps(updatedAt = "2026-08-30"), receivers = emptyList(), leaveMessageBlocks = emptyList(), - content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + content = DetailContent.Gallery(processingMethods = listOf(SERVER_PROCESSING_METHOD)), ) - /** 프로세스 사망 전에 저장돼 있던 폼 스냅샷 — 사용자가 제목을 고쳐 둔 상태다. */ + /** + * 프로세스 사망 전에 저장돼 있던 폼 스냅샷. + * + * 프리필이 실린 뒤 사용자가 **제목만** 고친 상태다 — 처리 방법은 서버 값 그대로다. + */ private fun restoredSnapshot(): String = """ {"type":"GALLERY_AND_FILES","selectedService":"$EDITED_SERVICE","receivers":[], - "processingMethods":[],"memorialPlaylistSongs":[]} + "processingMethods":[{"localId":0,"text":"$SERVER_PROCESSING_METHOD"}],"memorialPlaylistSongs":[]} """.trimIndent().replace("\n", "") - private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + private fun viewModel( + afternoteRepository: FakeAfternoteRepository, + prefillSeeded: Boolean = true, + ): AfternoteEditorViewModel = AfternoteEditorViewModel( savedStateHandle = SavedStateHandle( - mapOf( - "initialType" to AfternoteType.GALLERY_AND_FILES, - "itemId" to EDIT_ID, - "editor_form_snapshot_v4" to restoredSnapshot(), - ), + buildMap { + put("initialType", AfternoteType.GALLERY_AND_FILES) + put("itemId", EDIT_ID) + put("editor_form_snapshot_v4", restoredSnapshot()) + // 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식. + if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) + }, ), userRepository = FakeUserRepository.strict(), afternoteRepository = afternoteRepository, @@ -133,5 +212,6 @@ class AfternoteEditorProcessDeathPrefillTest { const val EDIT_ID = 73L const val EDITED_SERVICE = "사용자가 고친 제목" const val SERVER_SERVICE = "구글 포토" + const val SERVER_PROCESSING_METHOD = "파일 전달" } } From e53404fad6d29b01fe0b095481ebdb2a90a26871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 4 Sep 2026 00:57:44 +0900 Subject: [PATCH 002/116] =?UTF-8?q?fix(afternote):=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=ED=95=84=20=EA=B0=80=EB=93=9C=EB=A5=BC=20=E3=80=8C=ED=82=A4=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=E3=80=8D=EA=B0=80=20=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=9D=BC=20=E3=80=8C=EB=B3=B5=EC=9B=90=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=E3=80=8D=EC=9C=BC=EB=A1=9C=20=ED=8C=90=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#1732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적 반영. savedStateHandle.contains(EDITOR_FORM_SNAPSHOT_KEY) 는 문자열 존재만 본다. readFormSnapshotOrDefault() 가 디코딩 실패를 삼켜 빈 기본 폼으로 떨어지므로, 「표식 있음 + 문자열 있음 + 디코딩 실패」 조합에서 가드가 참이 되어 프리필이 막히고 빈 폼이 새 기준 스냅샷과 짝지어진다 — 이 PR 의 KDoc 이 스스로 피하겠다고 적은 「전부 지움」 저장이다. readFormSnapshotOrDefault() 가 RestoredForm(form, fromSnapshot) 을 돌려주게 하고 그 결과로 판정한다. 조건이 KDoc 의 주장과 일치한다. 회귀 테스트를 붙이고 결정 실험으로 확인했다 — 조건을 contains(...) 로 되돌리자 「스냅샷이 깨져 빈 폼으로 떨어진 복원은 프리필을 받는다」가 실패했다. --- .../editor/AfternoteEditorViewModel.kt | 49 ++++++++++++++++--- .../AfternoteEditorProcessDeathPrefillTest.kt | 31 +++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index b421139dc5..afb72c436f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -182,6 +182,15 @@ class AfternoteEditorViewModel encodeDefaults = true } + /** + * 스냅샷 복원 결과. **「키가 있는가」가 아니라 「실제로 복원됐는가」를 들고 있다.** + * + * [readFormSnapshotOrDefault] 는 디코딩 실패를 삼켜 빈 기본 폼으로 떨어진다. 키 존재만 보면 + * 「표식 있음 + 문자열 있음 + 디코딩 실패」 조합에서 가드가 참이 되어 프리필이 막히고, + * 빈 폼이 새 기준 스냅샷과 짝지어져 아래 KDoc 이 피하겠다고 적은 「전부 지움」 저장이 된다. + */ + private val restoredForm: RestoredForm = readFormSnapshotOrDefault() + /** * 이 ViewModel 이 «상세 프리필이 이미 실렸던» 폼 스냅샷에서 되살아났는가 (#1732). * @@ -195,18 +204,23 @@ class AfternoteEditorViewModel * 실패를 삼키므로, 표식은 남았는데 폼 스냅샷이 없는 조합이 가능하다. 그때 프리필까지 막으면 * 빈 폼이 기준 스냅샷과 짝지어져 「전부 지움」 저장이 된다 (#705·#1617 이 막은 그 경로다). * + * **그래서 「키가 있는가」가 아니라 「복원됐는가」([RestoredForm.fromSnapshot])를 본다.** + * 문자열이 남아 있어도 디코딩이 실패하면 폼은 빈 기본값이므로, 키 존재로 판정하면 위 조합을 + * 그대로 통과시킨다 — 스키마가 바뀌는 순간(키 접미사를 올리지 않은 채) 열리는 잠복 경로다. + * * 폼과 프리필의 값 비교로 대신하지 않는다. 계정 정보·남기실 말씀은 화면이 소유해 이 폼에 * 없으므로, 비밀번호만 고친 복원은 «폼이 같다» 로 읽혀 그 편집이 그대로 덮인다. */ + private val restoredFromSeededSnapshot: Boolean = route.itemId != null && - savedStateHandle.contains(EDITOR_FORM_SNAPSHOT_KEY) && + restoredForm.fromSnapshot && savedStateHandle.get(PREFILL_SEEDED_ITEM_ID_KEY) == route.itemId private val internalState = MutableStateFlow( InternalState( - form = readFormSnapshotOrDefault(), + form = restoredForm.form, originalType = route.initialType.takeIf { route.itemId != null }, isPrefillLoading = readEditItemId() != null, ), @@ -330,16 +344,35 @@ class AfternoteEditorViewModel private fun readEditItemId(): Long? = route.itemId - private fun readFormSnapshotOrDefault(): EditorFormState { + /** + * 저장된 폼 스냅샷을 읽는다. + * + * **복원 성공 여부를 함께 돌려준다.** 실패를 기본 폼으로 삼키기만 하면 호출부가 「빈 폼으로 + * 떨어졌다」와 「원래 빈 폼이었다」를 못 가른다 — [restoredFromSeededSnapshot] 이 그 차이로 + * 갈리므로 여기서 알려 줘야 한다. + */ + private fun readFormSnapshotOrDefault(): RestoredForm { val defaultForm = EditorFormState().withType(route.initialType) - val raw = savedStateHandle.get(EDITOR_FORM_SNAPSHOT_KEY) ?: return defaultForm + val raw = + savedStateHandle.get(EDITOR_FORM_SNAPSHOT_KEY) + ?: return RestoredForm(form = defaultForm, fromSnapshot = false) return runCatching { - formSnapshotJson - .decodeFromString(EditorFormSnapshot.serializer(), raw) - .toEditorFormState() - }.getOrElse { defaultForm } + RestoredForm( + form = + formSnapshotJson + .decodeFromString(EditorFormSnapshot.serializer(), raw) + .toEditorFormState(), + fromSnapshot = true, + ) + }.getOrElse { RestoredForm(form = defaultForm, fromSnapshot = false) } } + /** [readFormSnapshotOrDefault] 의 결과 — 폼과 «그 폼이 스냅샷에서 왔는가». */ + private data class RestoredForm( + val form: EditorFormState, + val fromSnapshot: Boolean, + ) + /** [EditorFormSnapshot] 직렬화. 실패 시 무시한다(용량 초과 등은 [EditorFormSnapshot] KDoc 참고). */ private fun persistFormSnapshot(form: EditorFormState) { runCatching { diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 667758a65a..692aab84ee 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -154,6 +154,34 @@ class AfternoteEditorProcessDeathPrefillTest { ) } + /** + * **스냅샷 문자열은 남았는데 디코딩이 실패한 복원도 프리필을 받아야 한다.** + * + * 가드가 「키가 있는가」만 보면 이 조합(표식 있음 + 문자열 있음 + 디코딩 실패)에서 참이 되어 + * 프리필이 막힌다. 그런데 폼은 실패를 삼켜 **빈 기본값**이라, 그 빈 폼이 새 기준 스냅샷과 + * 짝지어져 안 건드린 필드가 전부 「지움」으로 나간다 — #705·#1617 이 막은 그 사고다. + * + * 키 접미사(`_v4`)를 올리지 않은 채 스냅샷 스키마가 바뀌는 순간 열리는 잠복 경로다. + */ + @Test + fun `스냅샷이 깨져 빈 폼으로 떨어진 복원은 프리필을 받는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository, snapshot = "{ 이건 EditorFormSnapshot 이 아니다 }") + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + val prefill = viewModel.uiState.value.pendingPrefill + assertNotNull("빈 폼으로 떨어졌으면 프리필을 막으면 안 된다", prefill) + assertEquals( + SERVER_SERVICE, + (prefill?.content as EditorContentPrefill.Gallery).serviceName, + ) + } + private fun serverDetail() = Detail( id = EDIT_ID, @@ -178,6 +206,7 @@ class AfternoteEditorProcessDeathPrefillTest { private fun viewModel( afternoteRepository: FakeAfternoteRepository, prefillSeeded: Boolean = true, + snapshot: String = restoredSnapshot(), ): AfternoteEditorViewModel = AfternoteEditorViewModel( savedStateHandle = @@ -185,7 +214,7 @@ class AfternoteEditorProcessDeathPrefillTest { buildMap { put("initialType", AfternoteType.GALLERY_AND_FILES) put("itemId", EDIT_ID) - put("editor_form_snapshot_v4", restoredSnapshot()) + put("editor_form_snapshot_v4", snapshot) // 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식. if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) }, From 65501d49fc285b974db44c3abaf9919902cdd65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:51:30 +0900 Subject: [PATCH 003/116] =?UTF-8?q?refactor(afternote):=20=ED=95=A9?= =?UTF-8?q?=EB=B3=B8=20UserRepository=20=EB=8C=80=EC=8B=A0=20=EC=A2=81?= =?UTF-8?q?=EC=9D=80=20=EA=B3=84=EC=95=BD=20UserReceiverRepository=C2=B7My?= =?UTF-8?q?ProfileRepository=20=EB=A5=BC=20=EC=A3=BC=EC=9E=85=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#1743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/AfternoteDetailViewModel.kt | 8 ++-- .../editor/AfternoteEditorViewModel.kt | 6 +-- .../receiver/SelectReceiverViewModel.kt | 8 ++-- .../AfternoteAuthorExtendedTest.kt | 4 +- .../AfternoteAuthorImplementedCoverageTest.kt | 2 +- .../presentation/AfternoteAuthorTest.kt | 2 +- .../AfternoteAuthorTestFixtures.kt | 32 +++++---------- .../detail/AfternoteDetailViewModelTest.kt | 40 +++++++++---------- .../AfternoteEditorMemorialMediaRemoveTest.kt | 4 +- .../AfternoteEditorMissingBaselineTest.kt | 4 +- ...ternoteEditorMultiReceiverSelectionTest.kt | 10 ++--- .../AfternoteEditorPrefillFailureTest.kt | 6 +-- .../AfternoteEditorProcessDeathPrefillTest.kt | 4 +- ...rnoteEditorProcessingMethodDefaultsTest.kt | 4 +- ...noteEditorReceiverSelectionRecoveryTest.kt | 10 ++--- .../AfternoteEditorReceiverSnapshotTest.kt | 4 +- ...fternoteEditorServerMediaDeleteSaveTest.kt | 4 +- .../AfternoteEditorThumbnailRetryTest.kt | 4 +- .../AfternoteEditorValidationEventTest.kt | 4 +- .../receiver/SelectReceiverViewModelTest.kt | 22 +++++----- 20 files changed, 86 insertions(+), 96 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt index 1c43545a7c..3ca445e2b0 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.UserProfileCacheRepository -import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch * - 상세 조회: GET /api/afternotes/{id} * - 삭제: DELETE /api/afternotes/{id} * - 작성자 표시명: [UserProfileCacheRepository.getCachedUserName] 으로 즉시 채우고 - * [UserRepository.getMyProfile] 로 재검증한다 (네비게이션 인자로 전달하지 않음) + * [MyProfileRepository.getMyProfile] 로 재검증한다 (네비게이션 인자로 전달하지 않음) * - 상세 ID: [SavedStateHandle.toRoute]로 복원한 타입 안전 [AfternoteRoute.DetailRoute]에서 조회. * * [AfternoteDetailUiState] 를 그대로 들고 [uiState] 로 노출한다 — Loading/Success/Error 3분기. @@ -46,7 +46,7 @@ class AfternoteDetailViewModel constructor( @Assisted private val route: AfternoteRoute.DetailRoute, private val afternoteRepository: AfternoteRepository, - private val userRepository: UserRepository, + private val myProfileRepository: MyProfileRepository, private val userProfileRepository: UserProfileCacheRepository, private val errorReporter: ErrorReporter, ) : ViewModel() { @@ -100,7 +100,7 @@ class AfternoteDetailViewModel ?.takeIf { it.isNotBlank() } ?.let(::applyAuthorDisplayName) - runCatchingCancellable { userRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { profile -> applyAuthorDisplayName(profile.name) runCatchingCancellable { userProfileRepository.saveUserName(profile.name) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index e9f98b7988..a346a36909 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput @@ -171,7 +171,7 @@ class AfternoteEditorViewModel constructor( @Assisted private val route: AfternoteRoute.EditorFlowRoute, private val savedStateHandle: SavedStateHandle, - private val userRepository: UserRepository, + private val userReceiverRepository: UserReceiverRepository, private val afternoteRepository: AfternoteRepository, private val memorialThumbnailUploadRepository: MemorialThumbnailUploadRepository, private val resolveMemorialMediaForSave: ResolveMemorialMediaForSaveUseCase, @@ -406,7 +406,7 @@ class AfternoteEditorViewModel } private suspend fun loadAuthorReceivers() { - runCatchingCancellable { userRepository.getReceivers() } + runCatchingCancellable { userReceiverRepository.getReceivers() } .onSuccess { receivers -> internalState.update { it.copy(authorReceivers = receivers.toAfternoteEditorReceivers()) } }.onFailure { e -> diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt index 4db416b73d..14dd616ffe 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure @@ -38,7 +38,7 @@ data class SelectReceiverUiState( * 애프터노트 에디터의 수신자 선택 화면 ViewModel (#540). * * 서버 `GET users/receivers` 는 액세스 토큰으로 호출자를 식별하므로 파라미터가 없다 — - * 별도의 userId 없이 [UserRepository.getReceivers] 를 그대로 쓴다. + * 별도의 userId 없이 [UserReceiverRepository.getReceivers] 를 그대로 쓴다. * * 선택은 복수다 (#1426): 한 번 진입해 여러 명을 고르고, 완료 시 확정된 id 전체를 * `SELECTED_RECEIVER_IDS_KEY` 로 에디터에 돌려준다. 화면은 에디터 폼에 이미 담겨 있던 수신자를 @@ -53,7 +53,7 @@ data class SelectReceiverUiState( class SelectReceiverViewModel @Inject constructor( - private val userRepository: UserRepository, + private val userReceiverRepository: UserReceiverRepository, private val errorReporter: ErrorReporter, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -100,7 +100,7 @@ class SelectReceiverViewModel fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, loadFailed = false) } - runCatchingCancellable { userRepository.getReceivers() } + runCatchingCancellable { userReceiverRepository.getReceivers() } .onSuccess { receivers -> _uiState.update { state -> state.copy( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 1843359eeb..adf82e019a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -497,7 +497,7 @@ private fun detailViewModel( AfternoteDetailViewModel( route = AfternoteRoute.DetailRoute(itemId = itemId), afternoteRepository = repository, - userRepository = afternoteAuthorUserRepository(), + myProfileRepository = afternoteAuthorMyProfileRepository(), userProfileRepository = afternoteAuthorUserProfileRepository(), errorReporter = NoopAuthorErrorReporter, ) @@ -517,7 +517,7 @@ private fun editorViewModel( initialType = AfternoteType.SOCIAL_NETWORK, itemId = itemId, ), - userRepository = afternoteAuthorUserRepository(), + userReceiverRepository = afternoteAuthorUserReceiverRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 463ef7ca6a..446d5776bc 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -283,7 +283,7 @@ private fun implementedCoverageViewModel( AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = afternoteAuthorUserRepository(), + userReceiverRepository = afternoteAuthorUserReceiverRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index cf1abb0655..bb19c9fe64 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -139,7 +139,7 @@ class AfternoteAuthorTest { AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = afternoteAuthorUserRepository(), + userReceiverRepository = afternoteAuthorUserReceiverRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { Result.success("https://cdn.test/thumb.jpg") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt index c214db87a1..0474d92030 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTestFixtures.kt @@ -2,12 +2,11 @@ package com.afternote.feature.afternote.presentation import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeUserProfileCacheRepository -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.User -import com.afternote.core.model.user.UserConnectedAccount -import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -35,23 +34,18 @@ internal fun afternoteEditorSavedStateHandle( }, ) -/** app androidTest 공용 helper가 열어 두던 UserRepository 경계만 그대로 허용한다. */ -internal fun afternoteAuthorUserRepository(): FakeUserRepository = - FakeUserRepository.strict().apply { - profile = TEST_USER +/** 에디터·수신자 선택이 사용하는 수신자 조회 계약만 허용한다. */ +internal fun afternoteAuthorUserReceiverRepository(): FakeUserReceiverRepository = + FakeUserReceiverRepository.strict().apply { receiverState.value = listOf(TEST_RECEIVER) - pushSetting = TEST_PUSH_SETTING - connectedAccounts = testConnectedAccounts(profile.email) - - onReceiverListFlow = null onGetReceivers = null - onCreateReceiver = null + } + +/** 작성자 상세가 사용하는 프로필 조회 계약만 허용한다. */ +internal fun afternoteAuthorMyProfileRepository(): FakeMyProfileRepository = + FakeMyProfileRepository.strict().apply { + profile = TEST_USER onGetMyProfile = null - onUpdateMyProfile = null - onDeleteAccount = null - onGetMyPushSettings = null - onUpdateMyPushSettings = null - onGetConnectedAccounts = { testConnectedAccounts(profile.email) } } /** @@ -74,7 +68,3 @@ internal object NoopAuthorErrorReporter : ErrorReporter { private val TEST_USER = User("테스트 사용자", "test@afternote.local", null, null) private val TEST_RECEIVER = Receiver(7L, "김수신", "가족", "fake-auth-7") -private val TEST_PUSH_SETTING = UserPushSetting(true, true, true) - -private fun testConnectedAccounts(email: String): UserConnectedAccount = - UserConnectedAccount(true, false, false, false, false, email, null, null, null, null) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt index 118ea4a8d7..75ff275a14 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt @@ -2,16 +2,16 @@ package com.afternote.feature.afternote.presentation.detail import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeUserProfileCacheRepository -import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent import com.afternote.feature.afternote.domain.model.author.DetailCredentials import com.afternote.feature.afternote.domain.model.author.DetailTimestamps import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.afternoteAuthorMyProfileRepository import com.afternote.feature.afternote.presentation.afternoteAuthorUserProfileRepository -import com.afternote.feature.afternote.presentation.afternoteAuthorUserRepository import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -162,8 +162,8 @@ class AfternoteDetailViewModelTest { // 상세와 표시명은 출처가 다른 두 요청이라 도착 순서가 뒤집힌다. 이름이 늦는 쪽이 실제 경로 — // 그때 이미 그려진 Success 에 이름이 실려야 제목의 이름 세그먼트가 뒤늦게라도 채워진다. val profileGate = CompletableDeferred() - val userRepository = - afternoteAuthorUserRepository().apply { + val myProfileRepository = + afternoteAuthorMyProfileRepository().apply { onGetMyProfile = { profileGate.await() profile @@ -173,7 +173,7 @@ class AfternoteDetailViewModelTest { FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(detail(serviceName = "Instagram")) } } - val viewModel = viewModel(repository, userRepository = userRepository) + val viewModel = viewModel(repository, myProfileRepository = myProfileRepository) val states = recordStates(viewModel) // 이름이 도착하기 전 — 화면은 이미 상세를 그리고 있고 이름 자리는 비어 있다. @@ -181,7 +181,7 @@ class AfternoteDetailViewModelTest { profileGate.complete(Unit) - assertEquals(userRepository.profile.name, states.last().authorDisplayNameOrNull()) + assertEquals(myProfileRepository.profile.name, states.last().authorDisplayNameOrNull()) } @Test @@ -198,8 +198,8 @@ class AfternoteDetailViewModelTest { FakeAfternoteRepository.strict().apply { onGetDetail = { results.removeFirst() } } - val userRepository = afternoteAuthorUserRepository() - val viewModel = viewModel(repository, userRepository = userRepository) + val myProfileRepository = afternoteAuthorMyProfileRepository() + val viewModel = viewModel(repository, myProfileRepository = myProfileRepository) val states = recordStates(viewModel) viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 @@ -207,7 +207,7 @@ class AfternoteDetailViewModelTest { // 갱신이 만든 새 Success 도 이름을 들고 있어야 제목이 «…에 대한 기록» 으로 되돌아가지 않는다. assertEquals("Threads", states.last().serviceNameOrNull()) - assertEquals(userRepository.profile.name, states.last().authorDisplayNameOrNull()) + assertEquals(myProfileRepository.profile.name, states.last().authorDisplayNameOrNull()) } @Test @@ -215,8 +215,8 @@ class AfternoteDetailViewModelTest { runTest { // 원격만 쓰면 왕복이 끝나야 이름 세그먼트가 채워져, 진입마다 제목이 눈앞에서 다시 쓰인다. val profileGate = CompletableDeferred() - val userRepository = - afternoteAuthorUserRepository().apply { + val myProfileRepository = + afternoteAuthorMyProfileRepository().apply { onGetMyProfile = { profileGate.await() profile.copy(name = "서버 이름") @@ -228,7 +228,7 @@ class AfternoteDetailViewModelTest { FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(detail(serviceName = "Instagram")) } }, - userRepository = userRepository, + myProfileRepository = myProfileRepository, userProfileRepository = userProfileRepository, ) val states = recordStates(viewModel) @@ -250,18 +250,18 @@ class AfternoteDetailViewModelTest { afternoteAuthorUserProfileRepository().apply { onGetCachedUserName = { throw IOException("DataStore 읽기 실패") } } - val userRepository = afternoteAuthorUserRepository() + val myProfileRepository = afternoteAuthorMyProfileRepository() val viewModel = viewModel( FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(detail(serviceName = "Instagram")) } }, - userRepository = userRepository, + myProfileRepository = myProfileRepository, userProfileRepository = userProfileRepository, ) val states = recordStates(viewModel) - assertEquals(userRepository.profile.name, states.last().authorDisplayNameOrNull()) + assertEquals(myProfileRepository.profile.name, states.last().authorDisplayNameOrNull()) } @Test @@ -272,18 +272,18 @@ class AfternoteDetailViewModelTest { afternoteAuthorUserProfileRepository().apply { onSaveUserName = { throw IOException("DataStore 쓰기 실패") } } - val userRepository = afternoteAuthorUserRepository() + val myProfileRepository = afternoteAuthorMyProfileRepository() val viewModel = viewModel( FakeAfternoteRepository.strict().apply { onGetDetail = { Result.success(detail(serviceName = "Instagram")) } }, - userRepository = userRepository, + myProfileRepository = myProfileRepository, userProfileRepository = userProfileRepository, ) val states = recordStates(viewModel) - assertEquals(userRepository.profile.name, states.last().authorDisplayNameOrNull()) + assertEquals(myProfileRepository.profile.name, states.last().authorDisplayNameOrNull()) assertTrue(states.last() is AfternoteDetailUiState.Success) } @@ -419,13 +419,13 @@ class AfternoteDetailViewModelTest { private fun viewModel( repository: FakeAfternoteRepository, errorReporter: ErrorReporter = NoopAuthorErrorReporter, - userRepository: FakeUserRepository = afternoteAuthorUserRepository(), + myProfileRepository: FakeMyProfileRepository = afternoteAuthorMyProfileRepository(), userProfileRepository: FakeUserProfileCacheRepository = afternoteAuthorUserProfileRepository(), ): AfternoteDetailViewModel = AfternoteDetailViewModel( route = AfternoteRoute.DetailRoute(itemId = 73L), afternoteRepository = repository, - userRepository = userRepository, + myProfileRepository = myProfileRepository, userProfileRepository = userProfileRepository, errorReporter = errorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index cb68335e94..1638219098 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -163,7 +163,7 @@ class AfternoteEditorMemorialMediaRemoveTest { AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = repositoryProxy(), + userReceiverRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt index 3603e8e72c..8e25f01c09 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent @@ -149,7 +149,7 @@ class AfternoteEditorMissingBaselineTest { "itemId" to EDIT_ID, ), ), - userRepository = FakeUserRepository.strict(), + userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt index db0a53a040..4417ba2768 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -161,14 +161,14 @@ class AfternoteEditorMultiReceiverSelectionTest { assertNull("정상 반영에는 오류를 세우지 않는다", viewModel.uiState.value.error) } - private fun repositoryWith(vararg receivers: Receiver): FakeUserRepository = - FakeUserRepository.strict().apply { onGetReceivers = { receivers.toList() } } + private fun repositoryWith(vararg receivers: Receiver): FakeUserReceiverRepository = + FakeUserReceiverRepository.strict().apply { onGetReceivers = { receivers.toList() } } - private fun viewModel(userRepository: FakeUserRepository): AfternoteEditorViewModel = + private fun viewModel(userRepository: FakeUserReceiverRepository): AfternoteEditorViewModel = AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), - userRepository = userRepository, + userReceiverRepository = userRepository, afternoteRepository = unusedProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt index f72b85f393..3c294434b3 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.afternote.domain.model.author.Detail @@ -244,7 +244,7 @@ class AfternoteEditorPrefillFailureTest { route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = afternoteEditorSavedStateHandle(initialType = AfternoteType.SOCIAL_NETWORK, itemId = null), - userRepository = unusedProxy(), + userReceiverRepository = unusedProxy(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, @@ -293,7 +293,7 @@ class AfternoteEditorPrefillFailureTest { return AfternoteEditorViewModel( route = editorHandle.editorFlowRoute(), savedStateHandle = editorHandle, - userRepository = unusedProxy(), + userReceiverRepository = unusedProxy(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index cd8bbdf54e..e52cb182d7 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.Detail import com.afternote.feature.afternote.domain.model.author.DetailContent @@ -221,7 +221,7 @@ class AfternoteEditorProcessDeathPrefillTest { if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) }, ), - userRepository = FakeUserRepository.strict(), + userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index bf16a8850b..938f3d39f1 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -94,7 +94,7 @@ class AfternoteEditorProcessingMethodDefaultsTest { AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = repositoryProxy(), + userReceiverRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index 5927f4a336..80ef17118c 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -111,14 +111,14 @@ class AfternoteEditorReceiverSelectionRecoveryTest { ) } - private fun repositoryWith(receivers: suspend () -> List): FakeUserRepository = - FakeUserRepository.strict().apply { onGetReceivers = receivers } + private fun repositoryWith(receivers: suspend () -> List): FakeUserReceiverRepository = + FakeUserReceiverRepository.strict().apply { onGetReceivers = receivers } - private fun viewModel(userRepository: FakeUserRepository): AfternoteEditorViewModel = + private fun viewModel(userReceiverRepository: FakeUserReceiverRepository): AfternoteEditorViewModel = AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), - userRepository = userRepository, + userReceiverRepository = userReceiverRepository, afternoteRepository = unusedProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index 815219d30d..de27b758c6 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput @@ -106,7 +106,7 @@ class AfternoteEditorReceiverSnapshotTest { AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = repositoryProxy(), + userReceiverRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index a8ba6767a2..7a7bc2adc0 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -13,7 +13,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnai import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter -import com.afternote.feature.afternote.presentation.afternoteAuthorUserRepository +import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo @@ -193,7 +193,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { return AfternoteEditorViewModel( route = savedStateHandle.editorFlowRoute(), savedStateHandle = savedStateHandle, - userRepository = afternoteAuthorUserRepository(), + userReceiverRepository = afternoteAuthorUserReceiverRepository(), afternoteRepository = repository, memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index 20869c74b1..1ec547faad 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -153,7 +153,7 @@ class AfternoteEditorThumbnailRetryTest { AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.MEMORIAL), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)), - userRepository = repositoryProxy(), + userReceiverRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), memorialThumbnailUploadRepository = uploads, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index d1cdf84eac..a43a15458d 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -105,7 +105,7 @@ class AfternoteEditorValidationEventTest { AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), - userRepository = repositoryProxy(), + userReceiverRepository = repositoryProxy(), afternoteRepository = repositoryProxy(), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt index df1d629541..2ff4bbe927 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt @@ -2,7 +2,7 @@ package com.afternote.feature.afternote.presentation.editor.receiver import androidx.lifecycle.SavedStateHandle import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import kotlinx.coroutines.CompletableDeferred @@ -47,7 +47,7 @@ class SelectReceiverViewModelTest { fun `진입 시 수신자 목록을 불러와 에디터 표시 모델로 채운다`() = runTest { val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf( Receiver(1L, "김혜성", "아들", "auth-1"), @@ -70,7 +70,7 @@ class SelectReceiverViewModelTest { fun `로드가 끝나기 전에는 로딩 상태다`() = runTest { val gate = CompletableDeferred>() - val repository = FakeUserRepository(onGetReceivers = { gate.await() }) + val repository = FakeUserReceiverRepository(onGetReceivers = { gate.await() }) val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() @@ -92,7 +92,7 @@ class SelectReceiverViewModelTest { fun `로드 실패 시 실패 상태를 남기고 텔레메트리에 기록한다`() = runTest { val reporter = RecordingErrorReporter() - val repository = FakeUserRepository(onGetReceivers = { error("server down") }) + val repository = FakeUserReceiverRepository(onGetReceivers = { error("server down") }) val viewModel = SelectReceiverViewModel(repository, reporter, SavedStateHandle()) runCurrent() @@ -108,7 +108,7 @@ class SelectReceiverViewModelTest { fun `실패 후 다시 시도가 성공하면 실패 상태를 걷어낸다`() = runTest { val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf(Receiver(1L, "김혜성", "아들", "auth-1")), onGetReceivers = { error("server down") }, ) @@ -196,7 +196,7 @@ class SelectReceiverViewModelTest { runTest { // 미저장 폼을 둔 채 설정에서 수신자를 지우고 돌아온 경우 — 폼엔 남았지만 목록엔 없다. val gate = CompletableDeferred>() - val repository = FakeUserRepository(onGetReceivers = { gate.await() }) + val repository = FakeUserReceiverRepository(onGetReceivers = { gate.await() }) val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() @@ -217,7 +217,7 @@ class SelectReceiverViewModelTest { fun `재조회로 목록에서 사라진 수신자 선택만 해제되고 나머지는 남는다`() = runTest { val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf( Receiver(1L, "김혜성", "아들", "auth-1"), @@ -240,7 +240,7 @@ class SelectReceiverViewModelTest { fun `재조회 후에도 목록에 남아 있는 수신자 선택은 유지된다`() = runTest { val repository = - FakeUserRepository(receivers = listOf(Receiver(1L, "김혜성", "아들", "auth-1"))) + FakeUserReceiverRepository(receivers = listOf(Receiver(1L, "김혜성", "아들", "auth-1"))) val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() @@ -258,7 +258,7 @@ class SelectReceiverViewModelTest { runTest { val savedStateHandle = SavedStateHandle() val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf( Receiver(1L, "김혜성", "아들", "auth-1"), @@ -280,7 +280,7 @@ class SelectReceiverViewModelTest { runTest { val savedStateHandle = SavedStateHandle() val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf( Receiver(1L, "김혜성", "아들", "auth-1"), @@ -306,7 +306,7 @@ class SelectReceiverViewModelTest { */ private fun viewModelWithReceivers(formReceiverIds: List = emptyList()): SelectReceiverViewModel { val repository = - FakeUserRepository( + FakeUserReceiverRepository( receivers = listOf( Receiver(1L, "김혜성", "아들", "auth-1"), From 4f2deb5a5ad1dfdf6e5112ab23c174ed2636a20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:51:53 +0900 Subject: [PATCH 004/116] =?UTF-8?q?refactor(afternote):=20SaveAfternoteUse?= =?UTF-8?q?Case=20=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4=20(#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/editor/SaveAfternoteUseCase.kt | 48 +++++ .../editor/SaveAfternoteUseCaseTest.kt | 182 ++++++++++++++++++ .../editor/AfternoteEditorViewModel.kt | 28 +-- .../AfternoteAuthorExtendedTest.kt | 2 + .../AfternoteAuthorImplementedCoverageTest.kt | 2 + .../presentation/AfternoteAuthorTest.kt | 2 + .../AfternoteEditorMemorialMediaRemoveTest.kt | 2 + .../AfternoteEditorMissingBaselineTest.kt | 2 + ...ternoteEditorMultiReceiverSelectionTest.kt | 2 + .../AfternoteEditorPrefillFailureTest.kt | 3 + .../AfternoteEditorProcessDeathPrefillTest.kt | 2 + ...rnoteEditorProcessingMethodDefaultsTest.kt | 2 + ...noteEditorReceiverSelectionRecoveryTest.kt | 2 + .../AfternoteEditorReceiverSnapshotTest.kt | 2 + ...fternoteEditorServerMediaDeleteSaveTest.kt | 2 + .../AfternoteEditorThumbnailRetryTest.kt | 2 + .../AfternoteEditorValidationEventTest.kt | 2 + 17 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt create mode 100644 feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt new file mode 100644 index 0000000000..c937b3a6ba --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import javax.inject.Inject + +/** + * 완성된 [SaveAfternoteCommand] 를 해석하는 **단일 경계**. + * + * 도입 근거는 「비즈니스 로직이 많다」가 아니라 *해석의 소재지* 다. [SaveAfternoteCommand] 는 도메인이 + * 소유한 닫힌(sealed) 명령이고, 그 5갈래(Create 4종 · Update)를 [AfternoteRepository] 의 어느 메서드로 + * 보낼지는 명령 정의와 짝을 이루는 도메인 지식이다. 명령이 도메인에 있는 한 해석도 도메인에 있어야 + * 명령에 갈래가 늘 때 고칠 곳이 한 곳으로 닫힌다 — 화면이 몇 개든, 호출부가 지금 하나든 상관없다. + * + * 과거 `#246` 은 이 UseCase 를 «Repository 프록시» 로 보고 제거했지만, 같은 `when` 은 사라지지 않고 + * `AfternoteEditorViewModel` 로 옮겨갔을 뿐이었다. 즉 제거된 것은 중복이 아니라 경계였다 (`#1694`). + * + * 여기서 하지 않는 일 — 입력 검증, UI 폼 매핑, 미디어 입력 선택, 저장 상태·문구·네비게이션. + * 그 책임들은 명령을 *만드는* 쪽(presentation)에 그대로 남는다. + */ +class SaveAfternoteUseCase + @Inject + constructor( + private val afternoteRepository: AfternoteRepository, + ) { + /** + * Repository 의 [Result] 를 감싸거나 다시 던지지 않고 그대로 돌려준다 — 저장 실패의 해석은 + * 호출부(에러 문구·재시도)의 몫이고, `runCatching` 을 두르지 않아 코루틴 취소도 삼키지 않는다. + * + * @return 생성·수정된 애프터노트 id. + */ + suspend operator fun invoke(command: SaveAfternoteCommand): Result = + when (command) { + is SaveAfternoteCommand.Create -> { + when (val input = command.input) { + is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) + is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) + is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) + is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) + } + } + + is SaveAfternoteCommand.Update -> { + afternoteRepository.update(command.id, command.payload) + } + } + } diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt new file mode 100644 index 0000000000..88bf8c8c20 --- /dev/null +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt @@ -0,0 +1,182 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload +import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SaveAfternoteUseCase] 가 소유하는 것 — [SaveAfternoteCommand] 5갈래의 해석 경계. + * + * 고정하는 계약: + * 1. Create 4종(Social·Business·Gallery·Memorial)이 각각 *정확히 하나의* Repository 메서드로만 간다. + * (기록 목록을 전부 세어, 목표 외 메서드가 0회임을 같이 단언한다 — 라우팅이 옆칸으로 새는 회귀를 잡는다) + * 2. payload 를 가공하지 않고 그대로 넘긴다. Update 는 id 와 payload 를 둘 다 바꾸지 않는다. + * 3. Repository 의 성공·실패 [Result] 를 감싸지 않고 그대로 돌려준다. + * 4. `runCatching` 을 두르지 않아 코루틴 취소를 [Result.failure] 로 삼키지 않는다. + */ +class SaveAfternoteUseCaseTest { + private val accountPayload = + CreateAccountPayload( + title = "인스타그램", + processingMethods = listOf("계정 삭제"), + ) + + private val galleryPayload = + CreateGalleryPayload( + title = "사진첩", + processingMethods = listOf("전달"), + ) + + private val memorialPayload = + CreateMemorialPayload( + title = "장례식", + memorial = + MemorialWritePayload( + memorialPhotoUrl = null, + songs = emptyList(), + memorialVideo = null, + ), + ) + + @Test + fun `Social 생성은 createSocial 만 호출하고 payload 를 그대로 넘긴다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.socialPayloads) + repository.assertOnlyCalled(SOCIAL) + } + + @Test + fun `Business 생성은 createBusiness 만 호출한다 - 같은 스키마의 createSocial 로 새지 않는다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Business(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.businessPayloads) + repository.assertOnlyCalled(BUSINESS) + } + + @Test + fun `Gallery 생성은 createGallery 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(galleryPayload), repository.galleryPayloads) + repository.assertOnlyCalled(GALLERY) + } + + @Test + fun `Memorial 생성은 createMemorial 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(memorialPayload), repository.memorialPayloads) + repository.assertOnlyCalled(MEMORIAL) + } + + @Test + fun `Update 는 id 와 payload 를 바꾸지 않고 update 로만 보낸다`() { + val repository = FakeAfternoteRepository() + val payload = + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + title = "바뀐 제목", + memorial = + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Unchanged, + songs = emptyList(), + memorialVideo = FieldPatch.Set(null), + ), + isDraft = false, + ) + + // 기본 update 는 대상이 없으면 실패하므로 성공 Result 를 열어 라우팅만 본다. + repository.onUpdate = { _, _ -> Result.success(42L) } + val result = repository.save(SaveAfternoteCommand.Update(id = 42L, payload = payload)) + + assertEquals(42L, result.getOrNull()) + assertEquals(listOf(42L to payload), repository.updateCalls) + assertSame(payload, repository.updateCalls.single().second) + repository.assertOnlyCalled(UPDATE) + } + + @Test + fun `Repository 성공 id 를 그대로 돌려준다`() { + val repository = FakeAfternoteRepository().apply { onCreateGallery = { Result.success(7L) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertEquals(7L, result.getOrNull()) + } + + @Test + fun `Repository 실패는 감싸지 않고 같은 예외로 돌려준다`() { + val failure = IllegalStateException("저장 실패") + val repository = FakeAfternoteRepository().apply { onCreateSocial = { Result.failure(failure) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isFailure) + assertSame(failure, result.exceptionOrNull()) + } + + @Test + fun `취소는 실패 Result 로 삼키지 않고 그대로 전파한다`() { + val repository = + FakeAfternoteRepository().apply { + onCreateMemorial = { throw CancellationException("저장 중 화면 이탈") } + } + + // 삼켰다면 Result.failure 로 정상 반환돼 assertThrows 가 실패한다. + assertThrows(CancellationException::class.java) { + repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + } + } + + private fun FakeAfternoteRepository.save(command: SaveAfternoteCommand): Result = + runBlocking { SaveAfternoteUseCase(this@save)(command) } + + /** [only] 로 지목한 저장 메서드만 1회 호출되고 나머지 4개는 0회임을 단언한다. */ + private fun FakeAfternoteRepository.assertOnlyCalled(only: String) { + val calls = + mapOf( + SOCIAL to socialPayloads.size, + BUSINESS to businessPayloads.size, + GALLERY to galleryPayloads.size, + MEMORIAL to memorialPayloads.size, + UPDATE to updateCalls.size, + ) + assertEquals(calls.keys.associateWith { if (it == only) 1 else 0 }, calls) + } + + private companion object { + const val SOCIAL = "createSocial" + const val BUSINESS = "createBusiness" + const val GALLERY = "createGallery" + const val MEMORIAL = "createMemorial" + const val UPDATE = "update" + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index a346a36909..4a957b5427 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -9,12 +9,12 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure -import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -175,6 +175,8 @@ class AfternoteEditorViewModel private val afternoteRepository: AfternoteRepository, private val memorialThumbnailUploadRepository: MemorialThumbnailUploadRepository, private val resolveMemorialMediaForSave: ResolveMemorialMediaForSaveUseCase, + // 이 클래스에 동명의 `saveAfternote(...)` 진입점이 이미 있어 `UseCase` 접미사를 남긴다. + private val saveAfternoteUseCase: SaveAfternoteUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { /** 진행 중인 prefill 조회 — 재시도가 이전 조회를 자르기 위한 핸들. */ @@ -579,7 +581,7 @@ class AfternoteEditorViewModel updateBaseline = updateBaseline, ).fold( onSuccess = { command -> - executeSaveCommand(command).fold( + saveAfternoteUseCase(command).fold( onSuccess = { id -> internalState.update { it.copy( @@ -597,28 +599,6 @@ class AfternoteEditorViewModel } } - /** - * [SaveAfternoteCommand] 분기에 따라 [AfternoteRepository] 의 적합한 메서드를 직접 호출한다. - * - * 과거에는 별도 `SaveAfternoteUseCase` 로 분리돼 있었으나, 단일 Repository 내 메서드 라우팅 - * 외에 비즈니스 로직이 없어 *약한 UseCase* (`#246`) 로 판단해 ViewModel 로 흡수. - */ - private suspend fun executeSaveCommand(command: SaveAfternoteCommand): Result = - when (command) { - is SaveAfternoteCommand.Create -> { - when (val input = command.input) { - is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) - is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) - is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) - is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) - } - } - - is SaveAfternoteCommand.Update -> { - afternoteRepository.update(command.id, command.payload) - } - } - // 영정 사진: 새로 고른 로컬 픽 우선 → 없으면 기존 원격 → 둘 다 없으면 없음. private fun photoMediaInput( picked: String?, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index adf82e019a..103020f356 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -45,6 +45,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.detail.AfternoteDetailDeleteResult import com.afternote.feature.afternote.presentation.detail.AfternoteDetailUiState @@ -547,6 +548,7 @@ private fun editorViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 446d5776bc..30df4dad97 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -21,6 +21,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -317,5 +318,6 @@ private fun implementedCoverageViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index bb19c9fe64..4c49b8b7c5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -13,6 +13,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock @@ -166,6 +167,7 @@ class AfternoteAuthorTest { ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index 1638219098..b90599027b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -171,6 +172,7 @@ class AfternoteEditorMemorialMediaRemoveTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt index 8e25f01c09..5d3e9809cd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError @@ -151,6 +152,7 @@ class AfternoteEditorMissingBaselineTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt index 4417ba2768..ae169570ee 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -176,6 +177,7 @@ class AfternoteEditorMultiReceiverSelectionTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt index 3c294434b3..511229516f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload @@ -254,6 +255,7 @@ class AfternoteEditorPrefillFailureTest { if (input == MediaInput.None) Result.success(null) else error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = RecordingErrorReporter(), ) backgroundScope.launch { viewModel.uiState.collect {} } @@ -308,6 +310,7 @@ class AfternoteEditorPrefillFailureTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = errorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index e52cb182d7..483a752428 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -223,6 +224,7 @@ class AfternoteEditorProcessDeathPrefillTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index 938f3d39f1..0aed91e3df 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editorFlowRoute import kotlinx.coroutines.Dispatchers @@ -102,6 +103,7 @@ class AfternoteEditorProcessingMethodDefaultsTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index 80ef17118c..e9b0b78d75 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -126,6 +127,7 @@ class AfternoteEditorReceiverSelectionRecoveryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index de27b758c6..878a3a96cb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import com.afternote.feature.afternote.presentation.editorFlowRoute import org.junit.Assert.assertEquals @@ -114,6 +115,7 @@ class AfternoteEditorReceiverSnapshotTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index 7a7bc2adc0..d33b57e7f4 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle @@ -207,6 +208,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index 1ec547faad..c9aa9d5fa2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -160,6 +161,7 @@ class AfternoteEditorThumbnailRetryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index a43a15458d..7057dd9a4a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteValidationError @@ -113,6 +114,7 @@ class AfternoteEditorValidationEventTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) From 2e26021e31efdcf50b4b56969aaddda0ac522eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:52:19 +0900 Subject: [PATCH 005/116] =?UTF-8?q?refactor(afternote):=20=EC=98=81?= =?UTF-8?q?=EC=A0=95=EC=82=AC=EC=A7=84=20=ED=8E=B8=EC=A7=91=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=99=80=20=EC=A0=80=EC=9E=A5=20=EC=B6=9C=EC=B2=98?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4=20(#1902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/AfternoteEditorRouteHelpers.kt | 4 +- .../editor/AfternoteEditorViewModel.kt | 26 ++---- .../editor/SaveAfternoteMemorialMedia.kt | 6 +- .../MemorialMediaSourceBottomSheet.kt | 2 +- .../editor/state/AfternoteTypeForm.kt | 9 +- .../editor/state/EditableMemorialPhoto.kt | 87 +++++++++++++++++++ .../editor/state/EditorFormMutations.kt | 6 +- .../editor/state/EditorFormState.kt | 3 +- .../AfternoteAuthorImplementedCoverageTest.kt | 9 +- .../AfternoteEditorMemorialMediaRemoveTest.kt | 85 ++++++++++++++++-- ...fternoteEditorServerMediaDeleteSaveTest.kt | 10 +-- .../EditorContentSignatureCompletenessTest.kt | 5 +- .../MemorialMediaRemovableTargetsTest.kt | 4 +- 13 files changed, 203 insertions(+), 53 deletions(-) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt index f69fa525ac..35b9c47e50 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo // 에디터 조립부가 쓰는 순수 헬퍼들이다. Route 파일에는 조립만 남긴다 (#1514). @@ -95,8 +96,7 @@ internal fun buildOnRegisterClick( memorialMedia = SaveAfternoteMemorialMedia( memorialVideo = form.memorialVideo ?: EditableMemorialVideo.empty(), - memorialPhotoUrl = form.memorialPhotoUrl, - pickedMemorialPhotoUri = form.pickedMemorialPhotoUri, + memorialPhoto = form.memorialPhoto ?: EditableMemorialPhoto.empty(), ), ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 4a957b5427..2bb8cbf657 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -25,6 +25,7 @@ import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditor import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorErrorEvent import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorUiState import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.withMemorialPhoto @@ -85,6 +86,7 @@ private data class ProcessingMethodSnap( /** * [SavedStateHandle]에 JSON으로 넣는 폼 스냅샷. 번들 전체 크기는 대략 500KB~1MB를 넘기지 않도록 설계해야 하며, * 그렇지 않으면 [android.os.TransactionTooLargeException]이 날 수 있다. 큰 Base64/data URL은 폼에 넣지 말고 URL·URI 문자열만 저장한다. + * 사진 값 객체는 기존 `pickedMemorialPhotoUri`·`memorialPhotoUrl` 두 키로 변환하므로 v4 JSON 호환성을 유지한다. */ @Serializable private data class EditorFormSnapshot( @@ -124,9 +126,8 @@ private data class EditorFormSnapshot( AfternoteType.MEMORIAL -> { AfternoteTypeForm.Memorial( - pickedPhotoUri = pickedMemorialPhotoUri, + photo = EditableMemorialPhoto.fromSnapshot(memorialPhotoUrl, pickedMemorialPhotoUri), video = memorialVideo ?: EditableMemorialVideo.empty(), - photoUrl = memorialPhotoUrl, playlistSongs = memorialPlaylistSongs, ) } @@ -147,9 +148,9 @@ private data class EditorFormSnapshot( ReceiverSnap(id = it.id, name = it.name, label = it.label) }, processingMethods = form.processingMethods.map { ProcessingMethodSnap(it.localId, it.text) }, - pickedMemorialPhotoUri = form.pickedMemorialPhotoUri, + pickedMemorialPhotoUri = form.memorialPhoto?.toSnapshot()?.selection, memorialVideo = form.memorialVideo, - memorialPhotoUrl = form.memorialPhotoUrl, + memorialPhotoUrl = form.memorialPhoto?.toSnapshot()?.persisted, memorialPlaylistSongs = form.memorialPlaylistSongs, ) } @@ -599,17 +600,6 @@ class AfternoteEditorViewModel } } - // 영정 사진: 새로 고른 로컬 픽 우선 → 없으면 기존 원격 → 둘 다 없으면 없음. - private fun photoMediaInput( - picked: String?, - existing: String?, - ): MediaInput = - when { - !picked.isNullOrBlank() -> MediaInput.Local(picked) - !existing.isNullOrBlank() -> MediaInput.Remote(existing) - else -> MediaInput.None - } - private suspend fun buildSaveCommand( editingId: Long?, typeForSave: AfternoteType, @@ -622,11 +612,7 @@ class AfternoteEditorViewModel val resolved = resolveMemorialMediaForSave( video = memorialMedia.memorialVideo.toMediaInput(), - photo = - photoMediaInput( - picked = memorialMedia.pickedMemorialPhotoUri, - existing = memorialMedia.memorialPhotoUrl, - ), + photo = memorialMedia.memorialPhoto.toMediaInput(), ).getOrElse { return Result.failure(it) } val command = diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt index 8a7920d876..ca6ebec2d0 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/SaveAfternoteMemorialMedia.kt @@ -1,12 +1,12 @@ package com.afternote.feature.afternote.presentation.editor +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo -/** 저장 시 추억 노트 미디어 필드. 영상의 출처와 기준값은 [memorialVideo]가 보존한다. */ +/** 저장 시 추억 노트 미디어 필드. 사진과 영상의 출처·기준값은 각 값 객체가 보존한다. */ internal data class SaveAfternoteMemorialMedia( val memorialVideo: EditableMemorialVideo = EditableMemorialVideo.empty(), - val memorialPhotoUrl: String? = null, - val pickedMemorialPhotoUri: String? = null, + val memorialPhoto: EditableMemorialPhoto = EditableMemorialPhoto.empty(), ) /** diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt index c4441cc354..8ef108dadf 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaSourceBottomSheet.kt @@ -34,7 +34,7 @@ internal enum class MemorialMediaTarget { */ internal fun EditorFormState.removableMemorialMediaTargets(): Set = buildSet { - if (!displayMemorialPhotoUri().isNullOrBlank()) add(MemorialMediaTarget.PHOTO) + if (memorialPhoto?.canRemove == true) add(MemorialMediaTarget.PHOTO) if (canRemoveMemorialVideo) add(MemorialMediaTarget.VIDEO) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt index bab21a382e..9efa4ea0d1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteTypeForm.kt @@ -81,18 +81,17 @@ sealed interface AfternoteTypeForm { * 카테고리 전용 필드. 영상의 서버 기준값·미저장 교체분은 [EditableMemorialVideo]가 감춘다. * * 시트의 삭제는 슬롯을 비운다 — 교체분과 서버 값을 함께. 비어 있는 서버 값은 저장 시 PATCH `null` - * 로 이어진다(#1597). 사진은 `pickedPhotoUri`·`photoUrl` 두 칸이 같은 규칙을 따른다. + * 로 이어진다(#1597). 사진도 [EditableMemorialPhoto]가 같은 규칙을 닫는다. */ @ConsistentCopyVisibility data class Memorial internal constructor( - val pickedPhotoUri: String? = null, + internal val photo: EditableMemorialPhoto = EditableMemorialPhoto.empty(), internal val video: EditableMemorialVideo = EditableMemorialVideo.empty(), - val photoUrl: String? = null, val playlistSongs: List = emptyList(), ) : AfternoteTypeForm { override val type = AfternoteType.MEMORIAL - fun displayPhotoUri(): String? = pickedPhotoUri ?: photoUrl + internal fun displayPhotoUri(): String? = photo.displayed /** * 미디어는 수정 진입 기준선과 비교해야 서버 원본 삭제도 미저장 변경으로 잡힌다(#1597). 영상은 @@ -149,7 +148,7 @@ sealed interface AfternoteTypeForm { thumbnailUrl = content.thumbnailUrl, ), ), - photoUrl = content.photoUrl, + photo = EditableMemorialPhoto.fromPersisted(content.photoUrl), playlistSongs = content.playlistSongs, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt new file mode 100644 index 0000000000..3349d3216c --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialPhoto.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.afternote.presentation.editor.state + +import com.afternote.feature.afternote.domain.repository.author.MediaInput + +/** + * 영정사진의 서버 기준값과 미저장 선택. 표시·삭제·업로드 출처를 한 경계에서 결정한다. + * 교체 전 서버 값은 이탈 가드와 기존 SavedStateHandle 스냅샷의 동일성을 위해 보존한다. + * 삭제는 [empty]로 두 층을 함께 비우며, 서버 값으로 되돌리는 동작은 없다. + */ +internal sealed class EditableMemorialPhoto private constructor() { + private data object Empty : EditableMemorialPhoto() + + private data class ServerOnly( + val persisted: String, + ) : EditableMemorialPhoto() + + private data class SelectionOnly( + val selection: String, + ) : EditableMemorialPhoto() + + private data class Replaced( + val persisted: String, + val selection: String, + ) : EditableMemorialPhoto() + + internal val displayed: String? + get() = + when (this) { + Empty -> null + is ServerOnly -> persisted + is SelectionOnly -> selection + is Replaced -> selection + } + + internal val canRemove: Boolean + get() = + when (this) { + Empty -> false + is ServerOnly -> persisted.isNotBlank() + is SelectionOnly -> selection.isNotBlank() + is Replaced -> selection.isNotBlank() + } + + internal fun withSelection(uri: String): EditableMemorialPhoto = + when (this) { + Empty -> SelectionOnly(uri) + is ServerOnly -> Replaced(persisted, uri) + is SelectionOnly -> SelectionOnly(uri) + is Replaced -> Replaced(persisted, uri) + } + + /** 기존 빈 문자열 처리도 유지하며 URL 형태로 출처를 재추론하지 않는다. */ + internal fun toMediaInput(): MediaInput = + when (this) { + Empty -> MediaInput.None + is ServerOnly -> persisted.asRemoteInput() + is SelectionOnly -> if (selection.isBlank()) MediaInput.None else MediaInput.Local(selection) + is Replaced -> if (selection.isBlank()) persisted.asRemoteInput() else MediaInput.Local(selection) + } + + /** 기존 JSON의 두 키를 유지하는 직렬화 경계. UI와 저장 명령은 이 표현을 사용하지 않는다. */ + internal fun toSnapshot(): Snapshot = + when (this) { + Empty -> Snapshot(null, null) + is ServerOnly -> Snapshot(persisted, null) + is SelectionOnly -> Snapshot(null, selection) + is Replaced -> Snapshot(persisted, selection) + } + + internal data class Snapshot( + val persisted: String?, + val selection: String?, + ) + + internal companion object { + internal fun empty(): EditableMemorialPhoto = Empty + + internal fun fromPersisted(uri: String?): EditableMemorialPhoto = if (uri == null) Empty else ServerOnly(uri) + + internal fun fromSnapshot( + persisted: String?, + selection: String?, + ): EditableMemorialPhoto = fromPersisted(persisted).let { photo -> selection?.let(photo::withSelection) ?: photo } + } +} + +private fun String.asRemoteInput(): MediaInput = if (isBlank()) MediaInput.None else MediaInput.Remote(this) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt index 21ac64356f..bab54b1029 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormMutations.kt @@ -31,15 +31,15 @@ internal fun EditorFormState.withType(type: AfternoteType): EditorFormState = internal fun EditorFormState.withService(service: String): EditorFormState = mapServiceForm { it.withService(service) } -/** 사진 선택은 `picked` 층에 실린다. 서버 사진은 `photoUrl` 에 그대로 남는다 — 저장 없이 나가면 그것이 사실이고, 이탈 가드가 기준선과 비교한다. */ -internal fun EditorFormState.withMemorialPhoto(uri: String): EditorFormState = mapMemorial { it.copy(pickedPhotoUri = uri) } +/** 사진 선택은 값 객체 안의 미저장 층을 교체하며, 서버 기준값은 이탈 가드 비교를 위해 유지한다. */ +internal fun EditorFormState.withMemorialPhoto(uri: String): EditorFormState = mapMemorial { it.copy(photo = it.photo.withSelection(uri)) } /** * 시트의 사진 삭제(#1114). 슬롯을 비운다 — 로컬 교체분과 서버 사진을 함께. 서버 삭제는 여기서 일어나지 * 않는다 — 두 칸이 빈 폼을 저장이 PATCH `null` 로 보낼 뿐이다(#1597). 저장 없이 나가면 서버 사진은 * 그대로다(이탈 가드). */ -internal fun EditorFormState.withMemorialPhotoRemoved(): EditorFormState = mapMemorial { it.copy(pickedPhotoUri = null, photoUrl = null) } +internal fun EditorFormState.withMemorialPhotoRemoved(): EditorFormState = mapMemorial { it.copy(photo = EditableMemorialPhoto.empty()) } /** * 영상 첨부는 통째로 갈린다 — 새 영상에는 썸네일이 아직 없고, 이전 영상의 썸네일을 물려주면 다른 diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt index c06199e32a..089c400f95 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditorFormState.kt @@ -27,7 +27,7 @@ data class EditorFormState( internal val memorialForm: AfternoteTypeForm.Memorial? get() = typeForm as? AfternoteTypeForm.Memorial - val pickedMemorialPhotoUri: String? get() = memorialForm?.pickedPhotoUri + internal val memorialPhoto: EditableMemorialPhoto? get() = memorialForm?.photo /** 서버 기준값과 이번 폼의 미저장 교체분을 함께 가진 영상 편집 상태. */ internal val memorialVideo: EditableMemorialVideo? get() = memorialForm?.video @@ -36,7 +36,6 @@ data class EditorFormState( /** 시트에 영상 삭제 항목을 내놓을지 — 표시된 층이 있으면 출처와 무관하게 지울 수 있다(#1597). */ internal val canRemoveMemorialVideo: Boolean get() = memorialVideo?.canRemove == true - val memorialPhotoUrl: String? get() = memorialForm?.photoUrl val memorialPlaylistSongs: List get() = memorialForm?.playlistSongs.orEmpty() fun displayMemorialPhotoUri(): String? = memorialForm?.displayPhotoUri() diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 30df4dad97..9212800326 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -27,6 +27,7 @@ import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorial import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import org.junit.Assert.assertEquals @@ -174,8 +175,10 @@ class AfternoteAuthorImplementedCoverageTest { .empty() .withSelection("content://videos/farewell") .withSelectionThumbnail("https://cdn.test/thumbnail.jpg"), - memorialPhotoUrl = "https://cdn.test/old-photo.jpg", - pickedMemorialPhotoUri = "content://photos/new-portrait", + memorialPhoto = + EditableMemorialPhoto + .fromPersisted("https://cdn.test/old-photo.jpg") + .withSelection("content://photos/new-portrait"), ), ) } @@ -249,7 +252,7 @@ class AfternoteAuthorImplementedCoverageTest { ), restored.displayedMemorialVideo, ) - assertEquals("content://photos/portrait", restored.pickedMemorialPhotoUri) + assertEquals("content://photos/portrait", restored.memorialPhoto?.toSnapshot()?.selection) assertEquals( listOf(Song("91", "첫 번째 노래", "가수 A", "https://cdn.test/cover.jpg")), restored.memorialPlaylistSongs, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index b90599027b..61ac63cd04 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -5,6 +5,7 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase @@ -18,6 +19,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -72,8 +77,20 @@ class AfternoteEditorMemorialMediaRemoveTest { viewModel.removeMemorialPhoto() - assertNull(viewModel.currentForm().pickedMemorialPhotoUri) - assertNull(viewModel.currentForm().memorialPhotoUrl) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.selection, + ) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.persisted, + ) assertNull(viewModel.currentForm().displayMemorialPhotoUri()) } @@ -109,8 +126,20 @@ class AfternoteEditorMemorialMediaRemoveTest { viewModel.removeMemorialPhoto() viewModel.removeMemorialVideo() - assertNull(viewModel.currentForm().pickedMemorialPhotoUri) - assertNull(viewModel.currentForm().memorialPhotoUrl) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.selection, + ) + assertNull( + viewModel + .currentForm() + .memorialPhoto + ?.toSnapshot() + ?.persisted, + ) assertNull(viewModel.currentForm().displayMemorialPhotoUri()) assertNull(viewModel.currentForm().displayedMemorialVideo?.url) assertNull(viewModel.currentForm().displayedMemorialVideo?.thumbnailUrl) @@ -134,12 +163,56 @@ class AfternoteEditorMemorialMediaRemoveTest { val restored = viewModel(handle).currentForm() assertNull(restored.displayedMemorialVideo) - assertNull(restored.pickedMemorialPhotoUri) - assertNull(restored.memorialPhotoUrl) + assertNull(restored.memorialPhoto?.toSnapshot()?.selection) + assertNull(restored.memorialPhoto?.toSnapshot()?.persisted) assertNull(restored.displayMemorialPhotoUri()) assertEquals(listOf("노래"), restored.memorialPlaylistSongs.map { it.title }) } + @Test + fun `빈 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip(null, null, MediaInput.None) + } + + @Test + fun `서버 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip("https://cdn.test/portrait.jpg", null, MediaInput.Remote("https://cdn.test/portrait.jpg")) + } + + @Test + fun `로컬 사진 상태를 기존 JSON 키로 저장하고 복원한다`() { + assertPhotoSnapshotRoundTrip(null, "content://photo", MediaInput.Local("content://photo")) + } + + @Test + fun `교체 중 사진의 서버 기준값과 로컬 선택을 함께 복원한다`() { + assertPhotoSnapshotRoundTrip("https://cdn.test/portrait.jpg", "content://photo", MediaInput.Local("content://photo")) + } + + private fun assertPhotoSnapshotRoundTrip( + persisted: String?, + selection: String?, + expectedInput: MediaInput, + ) { + val handle = memorialSavedStateHandle() + val original = viewModel(handle) + original.applyPrefill(memorialPrefill(photoUrl = persisted)) + if (selection != null) original.setMemorialPhoto(selection) + val encoded = requireNotNull(handle.get("editor_form_snapshot_v4")) + val fields = Json.parseToJsonElement(encoded).jsonObject + assertEquals(persisted?.let(::JsonPrimitive) ?: JsonNull, fields["memorialPhotoUrl"]) + assertEquals(selection?.let(::JsonPrimitive) ?: JsonNull, fields["pickedMemorialPhotoUri"]) + + // 같은 진입 경로의 새 SavedStateHandle로 실제 직렬화된 스냅샷만 복사한다. + val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v4", encoded) }) + val photo = requireNotNull(restored.currentForm().memorialPhoto) + assertEquals(original.currentForm(), restored.currentForm()) + assertEquals(selection ?: persisted, photo.displayed) + assertEquals(expectedInput, photo.toMediaInput()) + restored.removeMemorialPhoto() + assertEquals(MediaInput.None, requireNotNull(restored.currentForm().memorialPhoto).toMediaInput()) + } + private fun memorialSavedStateHandle(): SavedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)) private fun memorialPrefill( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index d33b57e7f4..9330705413 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -17,6 +17,7 @@ import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editorFlowRoute @@ -165,20 +166,19 @@ class AfternoteEditorServerMediaDeleteSaveTest { private fun EditorFormState.fullMemorialMediaForSave(): SaveAfternoteMemorialMedia = SaveAfternoteMemorialMedia( memorialVideo = memorialVideo ?: EditableMemorialVideo.empty(), - memorialPhotoUrl = memorialPhotoUrl, - pickedMemorialPhotoUri = pickedMemorialPhotoUri, + memorialPhoto = memorialPhoto ?: EditableMemorialPhoto.empty(), ) private fun assertServerMediaAndSongs(form: EditorFormState) { - assertEquals("https://cdn.test/portrait.jpg", form.memorialPhotoUrl) + assertEquals("https://cdn.test/portrait.jpg", form.memorialPhoto?.toSnapshot()?.persisted) assertEquals("https://cdn.test/farewell.mp4", form.displayedMemorialVideo?.url) assertEquals("https://cdn.test/thumbnail.jpg", form.displayedMemorialVideo?.thumbnailUrl) assertEquals(listOf("배경음악"), form.memorialPlaylistSongs.map { it.title }) } private fun assertDeletedMediaAndSongs(form: EditorFormState) { - assertNull(form.pickedMemorialPhotoUri) - assertNull(form.memorialPhotoUrl) + assertNull(form.memorialPhoto?.toSnapshot()?.selection) + assertNull(form.memorialPhoto?.toSnapshot()?.persisted) assertNull(form.displayMemorialPhotoUri()) assertNull(form.displayedMemorialVideo?.url) assertNull(form.displayedMemorialVideo?.thumbnailUrl) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt index 8087255ea7..20af2f2313 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt @@ -5,6 +5,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment @@ -111,7 +112,9 @@ class EditorContentSignatureCompletenessTest { val before = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state) val after = editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.Memorial(pickedPhotoUri = "content://photo")), + EditorFormState( + typeForm = AfternoteTypeForm.Memorial(photo = EditableMemorialPhoto.empty().withSelection("content://photo")), + ), state, ) assertNotEquals(before, after) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt index 6e49804e42..c3733a276b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialMediaRemovableTargetsTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.editor.memorial import com.afternote.feature.afternote.presentation.editor.state.AfternoteTypeForm +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment @@ -23,8 +24,7 @@ class MemorialMediaRemovableTargetsTest { EditorFormState( typeForm = AfternoteTypeForm.Memorial( - pickedPhotoUri = pickedPhotoUri, - photoUrl = photoUrl, + photo = EditableMemorialPhoto.fromSnapshot(photoUrl, pickedPhotoUri), video = EditableMemorialVideo .fromPersisted(MemorialVideoAttachment.ofOrNull(persistedVideoUrl)) From f6e90283112082ce539f252a55e13f99d4846435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:52:53 +0900 Subject: [PATCH 006/116] =?UTF-8?q?refactor(afternote):=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20MVI=EC=99=80=20=EC=86=8C=EB=B9=84=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EB=A5=BC=20=ED=86=B5=ED=95=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#1804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/testing/TouchTargetAssertionsTest.kt | 26 + .../core/ui/testing/TouchTargetAssertions.kt | 16 +- .../editor/AfternoteEditorContent.kt | 2 +- .../editor/AfternoteEditorContract.kt | 252 ++++++++ .../editor/AfternoteEditorRoute.kt | 79 ++- .../editor/AfternoteEditorRouteHelpers.kt | 33 +- .../editor/AfternoteEditorScreen.kt | 2 +- .../editor/AfternoteEditorViewModel.kt | 607 ++++++++++-------- .../selection/EditorServiceSelectionSheet.kt | 28 +- .../editor/state/AfternoteSaveState.kt | 21 +- .../navigation/AfternoteEditorFlowHost.kt | 11 +- .../EditorPrefillSkeletonScreenshotTest.kt | 12 +- ...itorServiceSelectionSheetScreenshotTest.kt | 10 +- .../AfternoteAuthorExtendedTest.kt | 59 +- .../AfternoteAuthorImplementedCoverageTest.kt | 122 ++-- .../presentation/AfternoteAuthorTest.kt | 31 +- .../editor/AfternoteEditorErrorTest.kt | 98 ++- .../AfternoteEditorMemorialMediaRemoveTest.kt | 124 ++-- .../AfternoteEditorMissingBaselineTest.kt | 22 +- ...ternoteEditorMultiReceiverSelectionTest.kt | 151 ++++- .../AfternoteEditorPrefillFailureTest.kt | 38 +- .../AfternoteEditorProcessDeathPrefillTest.kt | 22 +- ...rnoteEditorProcessingMethodDefaultsTest.kt | 51 +- ...noteEditorReceiverSelectionRecoveryTest.kt | 20 +- .../AfternoteEditorReceiverSnapshotTest.kt | 24 +- ...fternoteEditorServerMediaDeleteSaveTest.kt | 42 +- .../AfternoteEditorThumbnailRetryTest.kt | 34 +- .../AfternoteEditorValidationEventTest.kt | 30 +- .../EditorContentSignatureCompletenessTest.kt | 297 ++++----- .../EditorServiceSelectionSheetTest.kt | 21 +- .../konsist/MviContractKonsistTest.kt | 1 - .../ProductionVisibilityKonsistTest.kt | 7 - 32 files changed, 1459 insertions(+), 834 deletions(-) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt diff --git a/core/ui/src/test/kotlin/com/afternote/core/ui/testing/TouchTargetAssertionsTest.kt b/core/ui/src/test/kotlin/com/afternote/core/ui/testing/TouchTargetAssertionsTest.kt index efbfe833a7..72ad59f58d 100644 --- a/core/ui/src/test/kotlin/com/afternote/core/ui/testing/TouchTargetAssertionsTest.kt +++ b/core/ui/src/test/kotlin/com/afternote/core/ui/testing/TouchTargetAssertionsTest.kt @@ -339,6 +339,32 @@ class TouchTargetAssertionsTest { assertTrue(targets.single { it.name == "No tab state" }.lacksRequiredState()) } + /** 프레임워크 노드(예: ModalBottomSheet 스크림)를 빼도 나머지 위반은 그대로 잡혀야 한다. */ + @Test + fun `excluded target is skipped but the rest of the sweep still fails`() { + composeRule.setContent { + Column(Modifier.padding(64.dp)) { + Box(Modifier.size(48.dp).clickable(role = Role.Button, onClick = {}).semantics { contentDescription = "OK" }) + Box( + Modifier + .size(48.dp) + .clickable(onClick = {}) + .semantics { contentDescription = "Framework scrim" }, + ) + Box(Modifier.size(48.dp).clickable(role = Role.Button, onClick = {}).testTag("unnamed")) + } + } + + val error = + runCatching { + composeRule.assertAccessibleClickTargets(exclude = { it.name == "Framework scrim" }) + }.exceptionOrNull() + assertTrue(error is AssertionError) + assertFalse(error?.message.orEmpty().contains("Role 누락")) + assertTrue(error?.message.orEmpty().contains("접근 가능한 이름 누락")) + assertTrue(error?.message.orEmpty().contains("unnamed")) + } + @Test fun `assertion fails when there are zero enabled click targets`() { composeRule.setContent { Box(Modifier.fillMaxSize()) } diff --git a/core/ui/src/testFixtures/kotlin/com/afternote/core/ui/testing/TouchTargetAssertions.kt b/core/ui/src/testFixtures/kotlin/com/afternote/core/ui/testing/TouchTargetAssertions.kt index 0676742d3c..9c199412d2 100644 --- a/core/ui/src/testFixtures/kotlin/com/afternote/core/ui/testing/TouchTargetAssertions.kt +++ b/core/ui/src/testFixtures/kotlin/com/afternote/core/ui/testing/TouchTargetAssertions.kt @@ -115,9 +115,19 @@ fun SemanticsNodeInteractionsProvider.scanEnabledClickTargets(): List Boolean = { false }, +) { + val targets = scanEnabledClickTargets().filterNot(exclude) if (targets.isEmpty()) { throw AssertionError("활성 클릭 semantics가 하나도 없습니다") } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt index 7f6f2f6e06..fcfc5fc495 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt @@ -44,7 +44,7 @@ import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditor import com.afternote.feature.afternote.presentation.editor.state.EditorFormState @Composable -internal fun EditorContent( +private fun EditorContent( state: AfternoteEditorState, form: EditorFormState, typeContent: @Composable () -> Unit, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt new file mode 100644 index 0000000000..0237e5c269 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt @@ -0,0 +1,252 @@ +package com.afternote.feature.afternote.presentation.editor + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.editor.memorial.Song +import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorErrorEvent +import com.afternote.feature.afternote.presentation.editor.state.EditorFormState + +/** 편집기 공유 흐름이 ViewModel에 보내는 변경·소비 의도. */ +internal sealed interface AfternoteEditorIntent : MviIntent { + data class SetType( + val type: AfternoteType, + ) : AfternoteEditorIntent + + data class SetService( + val service: String, + ) : AfternoteEditorIntent + + data class SetMemorialPhoto( + val uri: String, + ) : AfternoteEditorIntent + + data object RemoveMemorialPhoto : AfternoteEditorIntent + + data class SetMemorialVideo( + val url: String, + ) : AfternoteEditorIntent + + data object RemoveMemorialVideo : AfternoteEditorIntent + + data class SetMemorialThumbnail( + val dataUrl: String, + ) : AfternoteEditorIntent + + data class AddMemorialPlaylistSongs( + val songs: List, + ) : AfternoteEditorIntent + + data class RemoveMemorialPlaylistSongs( + val selectionKeys: Set, + ) : AfternoteEditorIntent + + data object ClearMemorialPlaylistSongs : AfternoteEditorIntent + + data class DeleteReceiver( + val receiverId: Long, + ) : AfternoteEditorIntent + + data class AddReceiverIfAbsent( + val receiverId: Long, + val name: String, + val label: String, + ) : AfternoteEditorIntent + + data class ReplaceReceiversIfEmpty( + val receivers: List, + ) : AfternoteEditorIntent + + data class ApplyPrefill( + val prefill: EditorFormPrefill, + ) : AfternoteEditorIntent + + data class InitializeProcessingMethodDefaults( + val type: AfternoteType, + val methods: List, + ) : AfternoteEditorIntent + + data class AddProcessingMethod( + val text: String, + ) : AfternoteEditorIntent + + data class DeleteProcessingMethod( + val localId: Int, + ) : AfternoteEditorIntent + + data class EditProcessingMethod( + val localId: Int, + val newText: String, + ) : AfternoteEditorIntent + + data object RefreshAuthorReceivers : AfternoteEditorIntent + + data class UploadMemorialThumbnail( + val jpegBytes: ByteArray?, + ) : AfternoteEditorIntent + + data class MemorialThumbnailExtractionFailed( + val throwable: Throwable, + ) : AfternoteEditorIntent + + data object RetryMemorialThumbnail : AfternoteEditorIntent + + data class MemorialCaptureLaunchFailed( + val throwable: Throwable, + ) : AfternoteEditorIntent + + data class Save( + val payload: RegisterAfternotePayload, + val selectedReceiverIds: List, + val memorialMedia: SaveAfternoteMemorialMedia, + ) : AfternoteEditorIntent + + data object RetryPrefill : AfternoteEditorIntent + + data object ConsumePrefill : AfternoteEditorIntent + + data object ConsumeSaveSuccess : AfternoteEditorIntent + + data object ConsumeThumbnailUploaded : AfternoteEditorIntent + + data class ConsumeError( + val consumed: AfternoteEditorErrorEvent, + ) : AfternoteEditorIntent + + data class ReceiversSelected( + val receiverIds: List, + ) : AfternoteEditorIntent + + data object ApplyPendingReceiverSelection : AfternoteEditorIntent +} + +/** ViewModel의 작업 결과와 순수 폼 전이. */ +internal sealed interface AfternoteEditorReducerEvent : ReducerEvent { + data class TypeChanged( + val type: AfternoteType, + ) : AfternoteEditorReducerEvent + + data class ServiceChanged( + val service: String, + ) : AfternoteEditorReducerEvent + + data class MemorialPhotoChanged( + val uri: String, + ) : AfternoteEditorReducerEvent + + data object MemorialPhotoRemoved : AfternoteEditorReducerEvent + + data class MemorialVideoChanged( + val url: String, + ) : AfternoteEditorReducerEvent + + data object MemorialVideoRemoved : AfternoteEditorReducerEvent + + data class MemorialThumbnailChanged( + val dataUrl: String, + ) : AfternoteEditorReducerEvent + + data class PlaylistSongsAdded( + val songs: List, + ) : AfternoteEditorReducerEvent + + data class PlaylistSongsRemoved( + val selectionKeys: Set, + ) : AfternoteEditorReducerEvent + + data object PlaylistSongsCleared : AfternoteEditorReducerEvent + + data class ReceiverDeleted( + val receiverId: Long, + ) : AfternoteEditorReducerEvent + + data class ReceiverAdded( + val receiverId: Long, + val name: String, + val label: String, + ) : AfternoteEditorReducerEvent + + data class EmptyReceiversReplaced( + val receivers: List, + ) : AfternoteEditorReducerEvent + + data class PrefillApplied( + val prefill: EditorFormPrefill, + ) : AfternoteEditorReducerEvent + + data class ProcessingMethodsInitialized( + val type: AfternoteType, + val methods: List, + ) : AfternoteEditorReducerEvent + + data class ProcessingMethodAdded( + val text: String, + ) : AfternoteEditorReducerEvent + + data class ProcessingMethodDeleted( + val localId: Int, + ) : AfternoteEditorReducerEvent + + data class ProcessingMethodEdited( + val localId: Int, + val newText: String, + ) : AfternoteEditorReducerEvent + + data class Initialized( + val form: EditorFormState, + val originalType: AfternoteType?, + val isPrefillLoading: Boolean, + ) : AfternoteEditorReducerEvent + + data class AuthorReceiversLoaded( + val receivers: List, + ) : AfternoteEditorReducerEvent + + data class ThumbnailUploaded( + val url: String, + ) : AfternoteEditorReducerEvent + + data object ThumbnailExtractionRetried : AfternoteEditorReducerEvent + + data class ErrorRaised( + val error: AfternoteEditorError, + ) : AfternoteEditorReducerEvent + + data object SaveStarted : AfternoteEditorReducerEvent + + data class SaveSucceeded( + val id: Long, + ) : AfternoteEditorReducerEvent + + data class SaveFailed( + val error: AfternoteEditorError, + ) : AfternoteEditorReducerEvent + + data object PrefillStarted : AfternoteEditorReducerEvent + + data class PrefillLoaded( + val prefill: EditorFormPrefill, + val baseline: AfternoteEditorSnapshot, + val preserveRestoredForm: Boolean, + ) : AfternoteEditorReducerEvent + + data object PrefillFailed : AfternoteEditorReducerEvent + + data class SelectedReceiversApplied( + val receivers: List, + ) : AfternoteEditorReducerEvent + + data object PrefillConsumed : AfternoteEditorReducerEvent + + data object SaveSuccessConsumed : AfternoteEditorReducerEvent + + data object ThumbnailUploadConsumed : AfternoteEditorReducerEvent + + data class ErrorConsumed( + val consumed: AfternoteEditorErrorEvent, + ) : AfternoteEditorReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt index 50f0e28f0c..9036ac28e4 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt @@ -32,21 +32,32 @@ internal fun AfternoteEditorNavigation( val uiState by editViewModel.uiState.collectAsStateWithLifecycle() val state = rememberAfternoteEditorState( - getCurrentForm = editViewModel::currentForm, - setType = editViewModel::setType, - setService = editViewModel::setService, - setMemorialPhoto = editViewModel::setMemorialPhoto, - removeMemorialPhoto = editViewModel::removeMemorialPhoto, - setMemorialVideo = editViewModel::setMemorialVideo, - removeMemorialVideo = editViewModel::removeMemorialVideo, - addReceiverIfAbsent = editViewModel::addReceiverIfAbsent, - applyPrefill = editViewModel::applyPrefill, - setMemorialThumbnail = editViewModel::setMemorialThumbnail, - deleteReceiver = editViewModel::deleteReceiver, - replaceReceiversIfEmpty = editViewModel::replaceReceiversIfEmpty, - addProcessingMethod = editViewModel::addProcessingMethod, - deleteProcessingMethod = editViewModel::deleteProcessingMethod, - editProcessingMethod = editViewModel::editProcessingMethod, + getCurrentForm = { editViewModel.uiState.value.form }, + setType = { type -> editViewModel.onIntent(AfternoteEditorIntent.SetType(type)) }, + setService = { service -> editViewModel.onIntent(AfternoteEditorIntent.SetService(service)) }, + setMemorialPhoto = { uri -> editViewModel.onIntent(AfternoteEditorIntent.SetMemorialPhoto(uri)) }, + removeMemorialPhoto = { editViewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) }, + setMemorialVideo = { url -> editViewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(url)) }, + removeMemorialVideo = { editViewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) }, + addReceiverIfAbsent = { + receiverId, + name, + label, + -> + editViewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(receiverId, name, label)) + }, + applyPrefill = { prefill -> editViewModel.onIntent(AfternoteEditorIntent.ApplyPrefill(prefill)) }, + setMemorialThumbnail = { dataUrl -> editViewModel.onIntent(AfternoteEditorIntent.SetMemorialThumbnail(dataUrl)) }, + deleteReceiver = { receiverId -> editViewModel.onIntent(AfternoteEditorIntent.DeleteReceiver(receiverId)) }, + replaceReceiversIfEmpty = { receivers -> editViewModel.onIntent(AfternoteEditorIntent.ReplaceReceiversIfEmpty(receivers)) }, + addProcessingMethod = { text -> editViewModel.onIntent(AfternoteEditorIntent.AddProcessingMethod(text)) }, + deleteProcessingMethod = { localId -> editViewModel.onIntent(AfternoteEditorIntent.DeleteProcessingMethod(localId)) }, + editProcessingMethod = { + localId, + newText, + -> + editViewModel.onIntent(AfternoteEditorIntent.EditProcessingMethod(localId, newText)) + }, ) val selectedType = uiState.form.selectedType @@ -55,14 +66,16 @@ internal fun AfternoteEditorNavigation( val isProcessingMethodDefaultsInitializing = remember(selectedType) { mutableStateOf(true) } LaunchedEffect(selectedType) { - editViewModel.initializeProcessingMethodDefaults( - type = selectedType, - methods = defaultProcessingMethods, + editViewModel.onIntent( + AfternoteEditorIntent.InitializeProcessingMethodDefaults( + type = selectedType, + methods = defaultProcessingMethods, + ), ) isProcessingMethodDefaultsInitializing.value = false } - LaunchedEffect(Unit) { editViewModel.refreshAuthorReceivers() } + LaunchedEffect(Unit) { editViewModel.onIntent(AfternoteEditorIntent.RefreshAuthorReceivers) } LaunchedEffect(uiState.authorReceivers, editViewModel.isEditing) { if (!editViewModel.isEditing) { @@ -72,29 +85,27 @@ internal fun AfternoteEditorNavigation( // 선택 화면이 위에 쌓이는 동안 이 화면은 컴포지션에서 빠지므로, 복귀할 때마다 다시 돈다. LaunchedEffect(Unit) { - tryApplyReceiverSelection( - editViewModel, - ) + editViewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) } LaunchedEffect(uiState.pendingSaveSuccessId) { if (uiState.pendingSaveSuccessId != null) { onSaveSuccessNavigateHome() - editViewModel.onSaveSuccessConsumed() + editViewModel.onIntent(AfternoteEditorIntent.ConsumeSaveSuccess) } } val pendingThumbnailUrl = uiState.pendingThumbnailUrl LaunchedEffect(pendingThumbnailUrl) { if (pendingThumbnailUrl != null) { state.setMemorialThumbnail(pendingThumbnailUrl) - editViewModel.onThumbnailUploadedConsumed() + editViewModel.onIntent(AfternoteEditorIntent.ConsumeThumbnailUploaded) } } val pendingPrefill = uiState.pendingPrefill LaunchedEffect(pendingPrefill) { if (pendingPrefill != null) { state.applyFormPrefill(pendingPrefill) - editViewModel.onPrefillConsumed() + editViewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) } } @@ -132,7 +143,7 @@ internal fun AfternoteEditorNavigation( if (errorEvent?.error?.offersMemorialThumbnailRetry() == true) { EditorSnackbarAction( label = stringResource(R.string.afternote_editor_thumbnail_retry), - onPerform = editViewModel::retryMemorialThumbnail, + onPerform = { editViewModel.onIntent(AfternoteEditorIntent.RetryMemorialThumbnail) }, ) } else { null @@ -144,27 +155,31 @@ internal fun AfternoteEditorNavigation( snackbarMessage = snackbarMessage, snackbarAction = thumbnailRetryAction, onSnackbarMessageConsumed = { - errorEvent?.let(editViewModel::onErrorConsumed) + errorEvent?.let({ consumed -> editViewModel.onIntent(AfternoteEditorIntent.ConsumeError(consumed)) }) }, validationMessage = validationMessage, onValidationMessageConsumed = { - errorEvent?.let(editViewModel::onErrorConsumed) + errorEvent?.let({ consumed -> editViewModel.onIntent(AfternoteEditorIntent.ConsumeError(consumed)) }) }, content = { snackbarHostState -> // prefill 을 못 읽었으면 폼을 세우지 않는다 (#705) — 빈 폼으로 저장되면 서버가 기존 기록을 // 그 빈 값으로 덮는다. 이 갈래에서는 사유와 재시도만 노출하고 «등록» 도 함께 잠근다. if (uiState.isPrefillFailed) { - EditorPrefillErrorBody(onRetry = editViewModel::retryPrefill) + EditorPrefillErrorBody(onRetry = { editViewModel.onIntent(AfternoteEditorIntent.RetryPrefill) }) } else { AfternoteEditorBody( state = state, form = uiState.form, onNavigateToMemorialPlaylist = onNavigateToMemorialPlaylist, onNavigateToSelectReceiver = onNavigateToSelectReceiver, - onThumbnailBytesReady = editViewModel::uploadMemorialThumbnail, - onThumbnailExtractionFailed = editViewModel::onMemorialThumbnailExtractionFailed, + onThumbnailBytesReady = { jpegBytes -> + editViewModel.onIntent(AfternoteEditorIntent.UploadMemorialThumbnail(jpegBytes)) + }, + onThumbnailExtractionFailed = { throwable -> + editViewModel.onIntent(AfternoteEditorIntent.MemorialThumbnailExtractionFailed(throwable)) + }, thumbnailRetryToken = uiState.memorialThumbnailRetryToken, - onCaptureFailed = editViewModel::onMemorialCaptureLaunchFailed, + onCaptureFailed = { throwable -> editViewModel.onIntent(AfternoteEditorIntent.MemorialCaptureLaunchFailed(throwable)) }, snackbarHostState = snackbarHostState, isPrefillLoading = uiState.isPrefillLoading, isTypeSelectionEnabled = !editViewModel.isEditing, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt index 35b9c47e50..91b441a69b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt @@ -59,19 +59,6 @@ internal fun AfternoteEditorError.offersMemorialThumbnailRetry(): Boolean = AfternoteEditorError.Upload.Target.SAVE_MEDIA -> false } -/** - * 수신자 선택 화면이 남긴 id 전체를 폼에 반영한다 (#1426). - * - * 결과는 흐름 공유 ViewModel 이 1회 소비로 나른다 (#1698) — Nav2 의 «이전 엔트리 SavedStateHandle» - * 자리다. 소비할 값이 없으면 선택 화면을 거치지 않은 복귀라 아무것도 하지 않는다. 값이 있으면 그게 - * 곧 확정된 수신자 전체이므로 반영은 [AfternoteEditorViewModel.applySelectedReceivers] 에 맡긴다 - * («추가» 가 아니라 «교체» 인 이유는 그 KDoc 참고). - */ -internal suspend fun tryApplyReceiverSelection(viewModel: AfternoteEditorViewModel) { - val selectedIds = viewModel.consumeSelectedReceiverIds() ?: return - viewModel.applySelectedReceivers(selectedIds) -} - internal fun buildOnRegisterClick( editViewModel: AfternoteEditorViewModel, state: AfternoteEditorState, @@ -90,14 +77,16 @@ internal fun buildOnRegisterClick( state.passwordState.text .toString(), ) - editViewModel.saveAfternote( - payload = payload, - selectedReceiverIds = form.afternoteEditReceivers.map { it.id }, - memorialMedia = - SaveAfternoteMemorialMedia( - memorialVideo = form.memorialVideo ?: EditableMemorialVideo.empty(), - memorialPhoto = form.memorialPhoto ?: EditableMemorialPhoto.empty(), - ), + editViewModel.onIntent( + AfternoteEditorIntent.Save( + payload = payload, + selectedReceiverIds = form.afternoteEditReceivers.map { it.id }, + memorialMedia = + SaveAfternoteMemorialMedia( + memorialVideo = form.memorialVideo ?: EditableMemorialVideo.empty(), + memorialPhoto = form.memorialPhoto ?: EditableMemorialPhoto.empty(), + ), + ), ) } @@ -121,7 +110,7 @@ internal fun shouldDeferEditorBaselineCapture( * 폼이 기본 빈 값이라, 느린 상세 GET 을 앞질러 저장하면 수정(PATCH)이 그 빈 값으로 나가 기존 * 기록을 덮는다. `isPrefillLoading` 은 편집 진입에서만 true 라 신규 작성은 영향받지 않는다. * - * ViewModel 의 [AfternoteEditorViewModel.saveAfternote] 진입 가드와 **같은 규칙을 두 겹으로** 둔다 — + * ViewModel 의 [AfternoteEditorIntent.Save] 진입 가드와 **같은 규칙을 두 겹으로** 둔다 — * 화면이 막는 것은 사용자 경험이고, 저장 진입점에서 막는 것은 계약이다. */ internal fun isEditorSubmitEnabled( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt index c399b5f489..00195994f5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt @@ -215,7 +215,7 @@ private const val NO_ENTERED_CONTENT = "\u0003" * 카테고리 전용 입력은 판별자 없이 "넣은 값" 으로만 싣는다 — 구경은 되돌리는 비용이 탭 한 번이라 * 잃을 것이 없고, 값을 넣은 카테고리를 떠나면 그 값은 전환 시점에 이미 폐기되므로 지문이 달라진다. */ -internal fun editorContentSignature( +private fun editorContentSignature( form: EditorFormState, state: AfternoteEditorState, ): String { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 2bb8cbf657..d618383ec1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -2,11 +2,11 @@ package com.afternote.feature.afternote.presentation.editor import android.util.Log import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand @@ -54,12 +54,8 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -159,7 +155,7 @@ private data class EditorFormSnapshot( /** * 애프터노트 생성/수정 ViewModel. * - * **SSOT:** 일반 폼은 [internalState]의 [EditorFormState], Compose 텍스트 입력은 + * **SSOT:** 일반 폼은 [uiState]의 [EditorFormState], Compose 텍스트 입력은 * [com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState]가 소유한다. * 추억 플레이리스트 화면과 곡 추가 화면은 같은 flow-scoped ViewModel의 폼을 사용한다. * @@ -167,7 +163,7 @@ private data class EditorFormSnapshot( * 저장 API 의 HTTP·에러 바디 해석은 [AfternoteRepository] 구현이 도메인 예외로 변환한다. */ @HiltViewModel(assistedFactory = AfternoteEditorViewModel.Factory::class) -class AfternoteEditorViewModel +internal class AfternoteEditorViewModel @AssistedInject constructor( @Assisted private val route: AfternoteRoute.EditorFlowRoute, @@ -179,10 +175,13 @@ class AfternoteEditorViewModel // 이 클래스에 동명의 `saveAfternote(...)` 진입점이 이미 있어 `UseCase` 접미사를 남긴다. private val saveAfternoteUseCase: SaveAfternoteUseCase, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel(AfternoteEditorUiState()) { /** 진행 중인 prefill 조회 — 재시도가 이전 조회를 자르기 위한 핸들. */ private var prefillJob: Job? = null + /** 새 선택이 이전 수신자 조회와 폼 반영을 취소한다. */ + private var receiverSelectionJob: Job? = null + private val formSnapshotJson = Json { ignoreUnknownKeys = true @@ -224,137 +223,324 @@ class AfternoteEditorViewModel restoredForm.fromSnapshot && savedStateHandle.get(PREFILL_SEEDED_ITEM_ID_KEY) == route.itemId - private val internalState = - MutableStateFlow( - InternalState( - form = restoredForm.form, - originalType = route.initialType.takeIf { route.itemId != null }, - isPrefillLoading = readEditItemId() != null, - ), - ) + val isEditing: Boolean get() = route.itemId != null - val uiState: StateFlow = - internalState - .map { it.toUiState() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = AfternoteEditorUiState(form = internalState.value.form), - ) + override fun onIntent(intent: AfternoteEditorIntent) { + when (intent) { + is AfternoteEditorIntent.SetType -> { + if (currentState.form.selectedType != intent.type) savedStateHandle.remove(INITIALIZED_ACTION_TEMPLATE_TYPE_KEY) + dispatchForm(AfternoteEditorReducerEvent.TypeChanged(intent.type)) + } - /** 파사드/페이로드 빌더 등이 콜백 시점에 최신 폼 스냅샷을 읽기 위한 접근자. */ - fun currentForm(): EditorFormState = internalState.value.form + is AfternoteEditorIntent.SetService -> { + dispatchForm(AfternoteEditorReducerEvent.ServiceChanged(intent.service)) + } - val isEditing: Boolean get() = route.itemId != null + is AfternoteEditorIntent.SetMemorialPhoto -> { + dispatchForm(AfternoteEditorReducerEvent.MemorialPhotoChanged(intent.uri)) + } - /** - * 폼 SSOT 갱신의 유일한 통로. SavedState 스냅샷 직렬화도 함께 수행한다. - * 어떤 필드를 어떻게 바꿀지는 `EditorFormMutations.kt` 의 변환 규칙이 정한다. - */ - private fun mutateForm(block: (EditorFormState) -> EditorFormState) { - internalState.update { prev -> prev.copy(form = block(prev.form)) } - persistFormSnapshot(internalState.value.form) - } + AfternoteEditorIntent.RemoveMemorialPhoto -> { + dispatchForm(AfternoteEditorReducerEvent.MemorialPhotoRemoved) + } - fun setType(type: AfternoteType) { - if (currentForm().selectedType != type) { - savedStateHandle.remove(INITIALIZED_ACTION_TEMPLATE_TYPE_KEY) - } - mutateForm { it.withType(type) } - } + is AfternoteEditorIntent.SetMemorialVideo -> { + pendingThumbnailBytes = null + dispatchForm(AfternoteEditorReducerEvent.MemorialVideoChanged(intent.url)) + } - fun setService(service: String) = mutateForm { it.withService(service) } + AfternoteEditorIntent.RemoveMemorialVideo -> { + pendingThumbnailBytes = null + dispatchForm(AfternoteEditorReducerEvent.MemorialVideoRemoved) + } - fun setMemorialPhoto(uri: String) = mutateForm { it.withMemorialPhoto(uri) } + is AfternoteEditorIntent.SetMemorialThumbnail -> { + dispatchForm(AfternoteEditorReducerEvent.MemorialThumbnailChanged(intent.dataUrl)) + } - /** 시트의 사진 삭제 항목. 슬롯을 비운다 — 서버 삭제는 저장 시 명시적 `null` 로 나간다(#1597, #1717). */ - fun removeMemorialPhoto() = mutateForm { it.withMemorialPhotoRemoved() } + is AfternoteEditorIntent.AddMemorialPlaylistSongs -> { + if (intent.songs.isEmpty()) return + dispatchForm(AfternoteEditorReducerEvent.PlaylistSongsAdded(intent.songs)) + } - fun setMemorialVideo(url: String) { - // 영상이 갈리면 이전 영상의 썸네일 실패도 함께 무효다 — 남은 바이트로 재시도하면 다른 - // 영상의 그림이 붙는다. - pendingThumbnailBytes = null - mutateForm { it.withMemorialVideo(url) } - } + is AfternoteEditorIntent.RemoveMemorialPlaylistSongs -> { + if (intent.selectionKeys.isEmpty()) return + dispatchForm(AfternoteEditorReducerEvent.PlaylistSongsRemoved(intent.selectionKeys)) + } - /** 시트의 영상 삭제 항목. 표시된 영상이 사라지므로 그 영상의 썸네일 재시도 바이트도 함께 버린다. */ - fun removeMemorialVideo() { - pendingThumbnailBytes = null - mutateForm { it.withMemorialVideoRemoved() } - } + AfternoteEditorIntent.ClearMemorialPlaylistSongs -> { + dispatchForm(AfternoteEditorReducerEvent.PlaylistSongsCleared) + } - fun setMemorialThumbnail(dataUrl: String) = mutateForm { it.withMemorialThumbnail(dataUrl) } + is AfternoteEditorIntent.DeleteReceiver -> { + dispatchForm(AfternoteEditorReducerEvent.ReceiverDeleted(intent.receiverId)) + } - fun addMemorialPlaylistSongs(songs: List) { - if (songs.isEmpty()) return - mutateForm { form -> - form.withMemorialPlaylistSongs(form.memorialPlaylistSongs + songs) - } - } + is AfternoteEditorIntent.AddReceiverIfAbsent -> { + dispatchForm(AfternoteEditorReducerEvent.ReceiverAdded(intent.receiverId, intent.name, intent.label)) + } - fun removeMemorialPlaylistSongs(selectionKeys: Set) { - if (selectionKeys.isEmpty()) return - mutateForm { form -> - form.withMemorialPlaylistSongs( - form.memorialPlaylistSongs.filterNot { it.selectionKey in selectionKeys }, - ) + is AfternoteEditorIntent.ReplaceReceiversIfEmpty -> { + dispatchForm(AfternoteEditorReducerEvent.EmptyReceiversReplaced(intent.receivers)) + } + + is AfternoteEditorIntent.ApplyPrefill -> { + readEditItemId()?.let { savedStateHandle[PREFILL_SEEDED_ITEM_ID_KEY] = it } + dispatchForm(AfternoteEditorReducerEvent.PrefillApplied(intent.prefill)) + } + + is AfternoteEditorIntent.InitializeProcessingMethodDefaults -> { + if (isEditing || currentState.form.selectedType != intent.type) return + if (savedStateHandle.get(INITIALIZED_ACTION_TEMPLATE_TYPE_KEY) == intent.type.name) return + savedStateHandle[INITIALIZED_ACTION_TEMPLATE_TYPE_KEY] = intent.type.name + if (intent.methods.isEmpty() || currentState.form.processingMethods.isNotEmpty()) return + dispatchForm(AfternoteEditorReducerEvent.ProcessingMethodsInitialized(intent.type, intent.methods)) + } + + is AfternoteEditorIntent.AddProcessingMethod -> { + dispatchForm(AfternoteEditorReducerEvent.ProcessingMethodAdded(intent.text)) + } + + is AfternoteEditorIntent.DeleteProcessingMethod -> { + dispatchForm(AfternoteEditorReducerEvent.ProcessingMethodDeleted(intent.localId)) + } + + is AfternoteEditorIntent.EditProcessingMethod -> { + dispatchForm(AfternoteEditorReducerEvent.ProcessingMethodEdited(intent.localId, intent.newText)) + } + + AfternoteEditorIntent.RefreshAuthorReceivers -> { + refreshAuthorReceivers() + } + + is AfternoteEditorIntent.UploadMemorialThumbnail -> { + uploadMemorialThumbnail(intent.jpegBytes) + } + + is AfternoteEditorIntent.MemorialThumbnailExtractionFailed -> { + onMemorialThumbnailExtractionFailed(intent.throwable) + } + + AfternoteEditorIntent.RetryMemorialThumbnail -> { + retryMemorialThumbnail() + } + + is AfternoteEditorIntent.MemorialCaptureLaunchFailed -> { + onMemorialCaptureLaunchFailed(intent.throwable) + } + + is AfternoteEditorIntent.Save -> { + saveAfternote(intent.payload, intent.selectedReceiverIds, intent.memorialMedia) + } + + AfternoteEditorIntent.RetryPrefill -> { + retryPrefill() + } + + AfternoteEditorIntent.ConsumePrefill -> { + dispatch(AfternoteEditorReducerEvent.PrefillConsumed) + } + + AfternoteEditorIntent.ConsumeSaveSuccess -> { + dispatch(AfternoteEditorReducerEvent.SaveSuccessConsumed) + } + + AfternoteEditorIntent.ConsumeThumbnailUploaded -> { + dispatch(AfternoteEditorReducerEvent.ThumbnailUploadConsumed) + } + + is AfternoteEditorIntent.ConsumeError -> { + dispatch(AfternoteEditorReducerEvent.ErrorConsumed(intent.consumed)) + } + + is AfternoteEditorIntent.ReceiversSelected -> { + receiverSelectionJob?.cancel() + savedStateHandle[SELECTED_RECEIVER_IDS_KEY] = intent.receiverIds.toLongArray() + } + + AfternoteEditorIntent.ApplyPendingReceiverSelection -> { + val selectedIds = savedStateHandle.remove(SELECTED_RECEIVER_IDS_KEY)?.toList() ?: return + receiverSelectionJob?.cancel() + receiverSelectionJob = viewModelScope.launch { applySelectedReceivers(selectedIds) } + } } } - fun clearMemorialPlaylistSongs() { - mutateForm { it.withMemorialPlaylistSongs(emptyList()) } - } + override fun reduce( + state: AfternoteEditorUiState, + event: AfternoteEditorReducerEvent, + ): AfternoteEditorUiState = + when (event) { + is AfternoteEditorReducerEvent.TypeChanged -> { + state.copy(form = state.form.withType(event.type)) + } - fun deleteReceiver(receiverId: Long) = mutateForm { it.withReceiverDeleted(receiverId) } + is AfternoteEditorReducerEvent.ServiceChanged -> { + state.copy(form = state.form.withService(event.service)) + } - fun addReceiverIfAbsent( - receiverId: Long, - name: String, - label: String, - ) = mutateForm { it.withReceiverAddedIfAbsent(receiverId = receiverId, name = name, label = label) } + is AfternoteEditorReducerEvent.MemorialPhotoChanged -> { + state.copy(form = state.form.withMemorialPhoto(event.uri)) + } - fun replaceReceiversIfEmpty(receivers: List) = mutateForm { it.withReceiversReplacedIfEmpty(receivers) } + AfternoteEditorReducerEvent.MemorialPhotoRemoved -> { + state.copy(form = state.form.withMemorialPhotoRemoved()) + } - /** - * 상세 프리필을 폼에 싣는다. 화면이 계정 정보·남기실 말씀까지 실은 뒤 [onPrefillConsumed] 로 통보한다. - * - * 폼 스냅샷과 같은 번들에 «프리필이 실렸다» 표식을 남긴다 — 프로세스 사망 뒤 재조회가 - * 이 표식을 보고 복원된 편집을 덮지 않는다 ([restoredFromSeededSnapshot], #1732). - */ - fun applyPrefill(prefill: EditorFormPrefill) { - readEditItemId()?.let { savedStateHandle[PREFILL_SEEDED_ITEM_ID_KEY] = it } - mutateForm { it.withPrefillApplied(prefill) } - } + is AfternoteEditorReducerEvent.MemorialVideoChanged -> { + state.copy(form = state.form.withMemorialVideo(event.url)) + } - /** - * 신규 작성 화면의 카테고리 추천 처리 방법을 최초 한 번만 채운다. - * - * 초기화 표식을 폼 스냅샷과 같은 [SavedStateHandle]에 남겨, 사용자가 추천을 전부 지운 뒤 - * 재구성·프로세스 복원이 일어나도 다시 삽입하지 않는다. 카테고리를 실제로 바꾸면 - * [setType]이 표식을 지워 새 카테고리의 추천을 받을 수 있게 한다. - */ - fun initializeProcessingMethodDefaults( - type: AfternoteType, - methods: List, - ) { - if (isEditing || currentForm().selectedType != type) return - if (savedStateHandle.get(INITIALIZED_ACTION_TEMPLATE_TYPE_KEY) == type.name) return + AfternoteEditorReducerEvent.MemorialVideoRemoved -> { + state.copy(form = state.form.withMemorialVideoRemoved()) + } - savedStateHandle[INITIALIZED_ACTION_TEMPLATE_TYPE_KEY] = type.name - if (methods.isEmpty() || currentForm().processingMethods.isNotEmpty()) return - mutateForm { it.withProcessingMethodsInitialized(methods) } - } + is AfternoteEditorReducerEvent.MemorialThumbnailChanged -> { + state.copy(form = state.form.withMemorialThumbnail(event.dataUrl)) + } + + is AfternoteEditorReducerEvent.PlaylistSongsAdded -> { + state.copy( + form = + state.form.withMemorialPlaylistSongs(state.form.memorialPlaylistSongs + event.songs), + ) + } + + is AfternoteEditorReducerEvent.PlaylistSongsRemoved -> { + state.copy( + form = + state.form.withMemorialPlaylistSongs( + state.form.memorialPlaylistSongs.filterNot { + it.selectionKey in + event.selectionKeys + }, + ), + ) + } + + AfternoteEditorReducerEvent.PlaylistSongsCleared -> { + state.copy(form = state.form.withMemorialPlaylistSongs(emptyList())) + } + + is AfternoteEditorReducerEvent.ReceiverDeleted -> { + state.copy(form = state.form.withReceiverDeleted(event.receiverId)) + } + + is AfternoteEditorReducerEvent.ReceiverAdded -> { + state.copy(form = state.form.withReceiverAddedIfAbsent(event.receiverId, event.name, event.label)) + } + + is AfternoteEditorReducerEvent.EmptyReceiversReplaced -> { + state.copy(form = state.form.withReceiversReplacedIfEmpty(event.receivers)) + } + + is AfternoteEditorReducerEvent.PrefillApplied -> { + state.copy(form = state.form.withPrefillApplied(event.prefill)) + } + + is AfternoteEditorReducerEvent.ProcessingMethodsInitialized -> { + state.copy(form = state.form.withProcessingMethodsInitialized(event.methods)) + } + + is AfternoteEditorReducerEvent.ProcessingMethodAdded -> { + state.copy(form = state.form.withProcessingMethodAdded(event.text)) + } + + is AfternoteEditorReducerEvent.ProcessingMethodDeleted -> { + state.copy(form = state.form.withProcessingMethodDeleted(event.localId)) + } + + is AfternoteEditorReducerEvent.ProcessingMethodEdited -> { + state.copy(form = state.form.withProcessingMethodEdited(event.localId, event.newText)) + } + + is AfternoteEditorReducerEvent.Initialized -> { + state.copy(form = event.form, originalType = event.originalType, isPrefillLoading = event.isPrefillLoading) + } - fun addProcessingMethod(text: String) = mutateForm { it.withProcessingMethodAdded(text) } + is AfternoteEditorReducerEvent.AuthorReceiversLoaded -> { + state.copy(authorReceivers = event.receivers) + } + + is AfternoteEditorReducerEvent.ThumbnailUploaded -> { + state.copy(pendingThumbnailUrl = event.url) + } + + AfternoteEditorReducerEvent.ThumbnailExtractionRetried -> { + state.copy(memorialThumbnailRetryToken = state.memorialThumbnailRetryToken + 1) + } + + is AfternoteEditorReducerEvent.ErrorRaised -> { + state.withError(event.error) + } + + AfternoteEditorReducerEvent.SaveStarted -> { + state.copy(isSaving = true, errorEvent = null) + } + + is AfternoteEditorReducerEvent.SaveSucceeded -> { + state.copy(isSaving = false, savedId = event.id, pendingSaveSuccessId = event.id) + } - fun deleteProcessingMethod(localId: Int) = mutateForm { it.withProcessingMethodDeleted(localId) } + is AfternoteEditorReducerEvent.SaveFailed -> { + state.copy(isSaving = false).withError(event.error) + } + + AfternoteEditorReducerEvent.PrefillStarted -> { + state.copy(isPrefillLoading = true, isPrefillFailed = false) + } - fun editProcessingMethod( - localId: Int, - newText: String, - ) = mutateForm { it.withProcessingMethodEdited(localId = localId, newText = newText) } + is AfternoteEditorReducerEvent.PrefillLoaded -> { + state.copy( + originalType = event.prefill.type, + pendingPrefill = if (event.preserveRestoredForm) null else event.prefill, + isPrefillLoading = + state.isPrefillLoading && !event.preserveRestoredForm, + updateBaseline = event.baseline, + ) + } + + AfternoteEditorReducerEvent.PrefillFailed -> { + state.copy(isPrefillLoading = false, isPrefillFailed = true) + } + + is AfternoteEditorReducerEvent.SelectedReceiversApplied -> { + state.copy(form = state.form.withReceiversReplaced(event.receivers)) + } + + AfternoteEditorReducerEvent.PrefillConsumed -> { + state.copy(isPrefillLoading = false, pendingPrefill = null) + } + + AfternoteEditorReducerEvent.SaveSuccessConsumed -> { + state.copy(pendingSaveSuccessId = null) + } + + AfternoteEditorReducerEvent.ThumbnailUploadConsumed -> { + state.copy(pendingThumbnailUrl = null) + } + + is AfternoteEditorReducerEvent.ErrorConsumed -> { + if (state.errorEvent == event.consumed) state.copy(errorEvent = null) else state + } + } + + /** 폼 전이가 끝난 뒤 저장한다. 재실행될 수 있는 reducer에는 IO를 두지 않는다. */ + private fun dispatchForm(event: AfternoteEditorReducerEvent) { + dispatch(event) + persistFormSnapshot(currentState.form) + } + + private fun currentForm(): EditorFormState = currentState.form init { + dispatch( + AfternoteEditorReducerEvent.Initialized( + restoredForm.form, + route.initialType.takeIf { route.itemId != null }, + readEditItemId() != null, + ), + ) readEditItemId()?.let(::loadExistingAfternoteForEdit) } @@ -398,20 +584,21 @@ class AfternoteEditorViewModel } /** - * 작성자가 등록한 수신자 전체를 받아 [InternalState.authorReceivers] 에 채운다. + * 작성자가 등록한 수신자 전체를 받아 [AfternoteEditorUiState.authorReceivers] 에 채운다. * * 신규 작성 진입 시 1회 호출된다. 폼이 비어 있으면 화면이 이 목록으로 수신자를 채우고 * (`AfternoteNavGraphEditor` 의 `replaceReceiversIfEmpty`), 사용자는 불필요한 수신자를 지운다. * 수정 진입은 상세 응답 prefill 이 지정 수신자를 채우므로 이 목록을 쓰지 않는다. */ - fun refreshAuthorReceivers() { + private fun refreshAuthorReceivers() { viewModelScope.launch { loadAuthorReceivers() } } private suspend fun loadAuthorReceivers() { runCatchingCancellable { userReceiverRepository.getReceivers() } .onSuccess { receivers -> - internalState.update { it.copy(authorReceivers = receivers.toAfternoteEditorReceivers()) } + currentCoroutineContext().ensureActive() + dispatch(AfternoteEditorReducerEvent.AuthorReceiversLoaded(receivers.toAfternoteEditorReceivers())) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.AUTHOR_RECEIVER_LOAD, e) } @@ -425,7 +612,7 @@ class AfternoteEditorViewModel */ private var pendingThumbnailBytes: ByteArray? = null - fun uploadMemorialThumbnail(jpegBytes: ByteArray?) { + private fun uploadMemorialThumbnail(jpegBytes: ByteArray?) { if (jpegBytes == null) return pendingThumbnailBytes = jpegBytes viewModelScope.launch { @@ -434,12 +621,14 @@ class AfternoteEditorViewModel .onSuccess { url -> Log.d(TAG, "uploadMemorialThumbnail: success, url=$url") pendingThumbnailBytes = null - internalState.update { it.copy(pendingThumbnailUrl = url) } + dispatch(AfternoteEditorReducerEvent.ThumbnailUploaded(url)) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_THUMBNAIL_UPLOAD, e) - internalState.update { - it.withError(AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.THUMBNAIL)) - } + dispatch( + AfternoteEditorReducerEvent.ErrorRaised( + AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.THUMBNAIL), + ), + ) } } } @@ -450,13 +639,13 @@ class AfternoteEditorViewModel * 로컬 디코딩이라 [uploadMemorialThumbnail] 경로를 타지 않는다. 종전에는 기록만 하고 화면에는 * 아무 신호도 없어, 사용자는 썸네일 자리가 빈 이유도 되돌릴 방법도 알 수 없었다. */ - fun onMemorialThumbnailExtractionFailed(throwable: Throwable) { + private fun onMemorialThumbnailExtractionFailed(throwable: Throwable) { errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_THUMBNAIL_EXTRACT, throwable) // 뽑지 못했으니 재업로드할 바이트가 없다 — 재시도는 추출부터 다시 돌아야 한다. pendingThumbnailBytes = null - internalState.update { - it.withError(AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.THUMBNAIL_EXTRACT)) - } + dispatch( + AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.THUMBNAIL_EXTRACT)), + ) } /** @@ -465,13 +654,13 @@ class AfternoteEditorViewModel * 손에 바이트가 있으면(업로드 실패) 그대로 다시 올리고, 없으면(추출 실패) 토큰을 올려 화면이 * 프레임 추출부터 다시 돌게 한다. 어느 쪽이든 사용자가 고른 영상은 그대로 둔다. */ - fun retryMemorialThumbnail() { + private fun retryMemorialThumbnail() { val bytes = pendingThumbnailBytes if (bytes != null) { uploadMemorialThumbnail(bytes) return } - internalState.update { it.copy(memorialThumbnailRetryToken = it.memorialThumbnailRetryToken + 1) } + dispatch(AfternoteEditorReducerEvent.ThumbnailExtractionRetried) } /** @@ -497,16 +686,16 @@ class AfternoteEditorViewModel * 화면에는 "카메라를 사용할 수 없습니다" 한 줄만 나가고 사유가 지워지므로, UI 가 예외를 * 넘겨주지 않으면 저장공간 문제인지 카메라 앱 부재인지 콘솔에서 가를 수 없다. */ - fun onMemorialCaptureLaunchFailed(throwable: Throwable) { + private fun onMemorialCaptureLaunchFailed(throwable: Throwable) { errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_CAPTURE_LAUNCH, throwable) } - internal fun saveAfternote( + private fun saveAfternote( payload: RegisterAfternotePayload, selectedReceiverIds: List, memorialMedia: SaveAfternoteMemorialMedia, ) { - val editorState = internalState.value + val editorState = currentState if (editorState.isSaving) return // prefill 을 못 읽은 채로 저장하면 서버가 빈 폼 값으로 기존 기록을 덮는다 (#705). // 화면이 이미 저장 액션을 막지만, 저장 진입점은 여기 하나뿐이라 규칙도 여기서 지킨다. @@ -520,11 +709,11 @@ class AfternoteEditorViewModel // 못했다」 이고 진행 중은 「곧 도착한다」 다 — 한 갈래로 뭉치면 아직 읽는 중인 // 사용자에게 실패했다고 말하게 된다. if (editorState.isPrefillFailed) { - internalState.update { it.withError(AfternoteEditorError.PrefillUnavailable) } + dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.PrefillUnavailable)) return } if (editorState.isPrefillLoading) { - internalState.update { it.withError(AfternoteEditorError.PrefillNotReady) } + dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.PrefillNotReady)) return } @@ -539,9 +728,7 @@ class AfternoteEditorViewModel payload = payload, ) if (validationError != null) { - internalState.update { - it.withError(AfternoteEditorError.Validation(validationError)) - } + dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.Validation(validationError))) return } @@ -552,14 +739,12 @@ class AfternoteEditorViewModel // 「전부 지움」을 가릴 수 없어 빈 폼이 그대로 삭제 지시가 된다 (#1617). val updateBaseline = editorState.updateBaseline if (editingId != null && updateBaseline == null) { - internalState.update { it.withError(AfternoteEditorError.PrefillUnavailable) } + dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.PrefillUnavailable)) return } viewModelScope.launch { - internalState.update { - it.copy(isSaving = true, errorEvent = null) - } + dispatch(AfternoteEditorReducerEvent.SaveStarted) // 썸네일 URL 이 비어 있고 업로드에 실패한 바이트가 남아 있으면, payload 를 만들기 전에 그 // 바이트를 한 번 더 업로드해 본다. 여기서 놓치면 썸네일 없는 영상이 그대로 확정되고, // 재편집으로 들어와도 원격 URL 이라 프레임을 다시 뽑지 않는다 (#1550). 실패해도 저장은 @@ -584,13 +769,7 @@ class AfternoteEditorViewModel onSuccess = { command -> saveAfternoteUseCase(command).fold( onSuccess = { id -> - internalState.update { - it.copy( - isSaving = false, - savedId = id, - pendingSaveSuccessId = id, - ) - } + dispatch(AfternoteEditorReducerEvent.SaveSucceeded(id)) }, onFailure = { e -> handleSaveFailure(e) }, ) @@ -659,7 +838,7 @@ class AfternoteEditorViewModel * 뒤라면 부를 일이 없고(화면이 오류 상태에서만 버튼을 그린다), 신규 작성 진입은 [readEditItemId] * 가 null 이라 아무 일도 하지 않는다. */ - fun retryPrefill() { + private fun retryPrefill() { val afternoteId = readEditItemId() ?: return loadExistingAfternoteForEdit(afternoteId) } @@ -668,7 +847,7 @@ class AfternoteEditorViewModel * 수정 진입 시 기존 애프터노트를 읽어 폼에 실을 prefill 을 만든다. * * 실패를 «빈 폼» 으로 흘려보내지 않는다 (#705) — 서버 수정(PATCH)은 보낸 값으로 기존 기록을 - * 덮으므로, 못 읽은 상태의 빈 폼이 저장되면 기록이 소실된다. 그래서 실패는 [InternalState.isPrefillFailed] + * 덮으므로, 못 읽은 상태의 빈 폼이 저장되면 기록이 소실된다. 그래서 실패는 [AfternoteEditorUiState.isPrefillFailed] * 로 남겨 화면이 오류·재시도를 그리고 [saveAfternote] 가 저장을 막게 한다. */ private fun loadExistingAfternoteForEdit(afternoteId: Long) { @@ -676,35 +855,30 @@ class AfternoteEditorViewModel prefillJob?.cancel() prefillJob = viewModelScope.launch { - internalState.update { it.copy(isPrefillLoading = true, isPrefillFailed = false) } + dispatch(AfternoteEditorReducerEvent.PrefillStarted) afternoteRepository .getDetail(id = afternoteId) .onSuccess { detail -> val prefill = AfternoteEditorFormMapper.buildEditorFormPrefill(detail) // UI 레이어 파사드가 TextFieldState·SnapshotStateList 등 UI 상태를 갱신하도록 위임. - // skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [onPrefillConsumed] 로 통보한다 + // skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [AfternoteEditorIntent.ConsumePrefill] 로 통보한다 // (uiState 갱신 시점에 prefill 도착했어도 UI 가 form·TextFieldState 에 반영하기 전이라 // 여기서 끄면 skeleton 사라짐 → 빈 폼 → prefill 깜빡임 발생). // // 복원된 편집이 있으면 프리필을 싣지 않는다 (#1732). 조회 자체는 그대로 돈다 — - // 기준 스냅샷([InternalState.updateBaseline])이 없으면 저장이 막히고, 카테고리도 + // 기준 스냅샷([AfternoteEditorUiState.updateBaseline])이 없으면 저장이 막히고, 카테고리도 // 서버가 아는 값이어야 한다. 막는 것은 «폼에 덮어쓰기» 하나다. - internalState.update { - it.copy( - originalType = prefill.type, - pendingPrefill = if (restoredFromSeededSnapshot) null else prefill, - // 프리필을 싣지 않으면 UI 의 [onPrefillConsumed] 도 오지 않는다 — - // 그 경로에서는 skeleton 을 여기서 직접 걷는다. - isPrefillLoading = it.isPrefillLoading && !restoredFromSeededSnapshot, - // 화면에 뿌릴 prefill 과 별개로, 가공 전 원본을 저장 때 견줄 기준으로 남긴다. - // 이 값은 폼 변경을 따라가지 않는다 — 따라가면 비교할 대상이 사라진다 (#1617). - updateBaseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), - ) - } + dispatch( + AfternoteEditorReducerEvent.PrefillLoaded( + prefill = prefill, + baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + preserveRestoredForm = restoredFromSeededSnapshot, + ), + ) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.PREFILL_LOAD, e) // skeleton 은 걷되 빈 폼으로 넘기지 않는다 — 오류·재시도 상태로 남긴다. - internalState.update { it.copy(isPrefillLoading = false, isPrefillFailed = true) } + dispatch(AfternoteEditorReducerEvent.PrefillFailed) } } } @@ -713,10 +887,6 @@ class AfternoteEditorViewModel * UI 가 [AfternoteEditorUiState.pendingPrefill] 신호를 받아 폼·TextFieldState 에 모두 반영한 직후 호출. * skeleton 종료([AfternoteEditorUiState.isPrefillLoading] = false) + 신호 reset (pendingPrefill = null) 동시 처리. */ - fun onPrefillConsumed() { - internalState.update { it.copy(isPrefillLoading = false, pendingPrefill = null) } - } - private fun handleSaveFailure(e: Throwable) { val editorError = e.toAfternoteEditorError() // 필수 필드 검증에 걸린 실패(수신자 미선택 등)는 사용자가 채우면 풀리는 정상 경로라 기록하지 않는다 — @@ -725,9 +895,7 @@ class AfternoteEditorViewModel if (editorError !is AfternoteEditorError.Validation) { errorReporter.recordAfternoteFailure(AfternoteFailureStage.SAVE, e) } - internalState.update { - it.copy(isSaving = false).withError(editorError) - } + dispatch(AfternoteEditorReducerEvent.SaveFailed(editorError)) } /** @@ -737,11 +905,11 @@ class AfternoteEditorViewModel * 그래도 못 찾으면 [AfternoteEditorError.ReceiverSelectionUnavailable] 을 세워 화면이 알리게 한다. * 이 신호가 없으면 사용자가 고른 수신자가 아무 표시 없이 사라진다 (#1405). */ - suspend fun resolveSelectedReceiver(id: Long): AfternoteEditorReceiver? { + private suspend fun resolveSelectedReceiver(id: Long): AfternoteEditorReceiver? { findReceiverById(id)?.let { return it } loadAuthorReceivers() return findReceiverById(id) ?: run { - internalState.update { it.withError(AfternoteEditorError.ReceiverSelectionUnavailable) } + dispatch(AfternoteEditorReducerEvent.ErrorRaised(AfternoteEditorError.ReceiverSelectionUnavailable)) null } } @@ -756,97 +924,20 @@ class AfternoteEditorViewModel * 새로 들어온 id 만 [resolveSelectedReceiver] 로 해석하고, 해석 실패는 그쪽이 오류 이벤트로 * 알린다 — 그 id 만 빠지고 나머지 선택은 반영된다 (#1405). */ - suspend fun applySelectedReceivers(receiverIds: List) { + private suspend fun applySelectedReceivers(receiverIds: List) { val alreadyInForm = currentForm().afternoteEditReceivers.associateBy { it.id } val next = receiverIds.mapNotNull { id -> alreadyInForm[id] ?: resolveSelectedReceiver(id) } - mutateForm { it.withReceiversReplaced(next) } + currentCoroutineContext().ensureActive() + dispatchForm(AfternoteEditorReducerEvent.SelectedReceiversApplied(next)) } - private fun findReceiverById(id: Long): AfternoteEditorReceiver? = internalState.value.authorReceivers.find { it.id == id } - - // region Internal state shaping + private fun findReceiverById(id: Long): AfternoteEditorReceiver? = currentState.authorReceivers.find { it.id == id } - /** - * VM 내부에서만 다루는 평탄한 상태. - * public [AfternoteEditorUiState] 는 이 값을 [toUiState] 로 매핑해 노출한다. - */ - private data class InternalState( - val form: EditorFormState = EditorFormState(), - val originalType: AfternoteType? = null, - val authorReceivers: List = emptyList(), - val isSaving: Boolean = false, - val isPrefillLoading: Boolean = false, - val isPrefillFailed: Boolean = false, - val savedId: Long? = null, - val errorEvent: AfternoteEditorErrorEvent? = null, - val errorOccurrence: Long = 0L, - val pendingSaveSuccessId: Long? = null, - val pendingThumbnailUrl: String? = null, - val memorialThumbnailRetryToken: Int = 0, - val pendingPrefill: EditorFormPrefill? = null, - /** - * 수정 진입 시 받은 상세를 그대로 옮긴 **pristine baseline** (#1617). - * - * 저장 시 현재 폼과 견줘 **달라진 필드만** 요청에 싣는 기준이다. `null` 이면 기준이 없다는 - * 뜻이라(신규 작성이거나 상세 로드 실패) 종전처럼 전량을 싣는다. - */ - val updateBaseline: AfternoteEditorSnapshot? = null, - ) - - private fun InternalState.toUiState(): AfternoteEditorUiState = - AfternoteEditorUiState( - form = form, - authorReceivers = authorReceivers, - isSaving = isSaving, - isPrefillLoading = isPrefillLoading, - isPrefillFailed = isPrefillFailed, - savedId = savedId, - errorEvent = errorEvent, - pendingSaveSuccessId = pendingSaveSuccessId, - pendingThumbnailUrl = pendingThumbnailUrl, - memorialThumbnailRetryToken = memorialThumbnailRetryToken, - pendingPrefill = pendingPrefill, - ) - - fun onSaveSuccessConsumed() { - internalState.update { it.copy(pendingSaveSuccessId = null) } - } - - fun onThumbnailUploadedConsumed() { - internalState.update { it.copy(pendingThumbnailUrl = null) } - } - - private fun InternalState.withError(error: AfternoteEditorError): InternalState { + private fun AfternoteEditorUiState.withError(error: AfternoteEditorError): AfternoteEditorUiState { val nextOccurrence = errorOccurrence + 1L - return copy( - errorEvent = AfternoteEditorErrorEvent(error, nextOccurrence), - errorOccurrence = nextOccurrence, - ) + return copy(errorEvent = AfternoteEditorErrorEvent(error, nextOccurrence), errorOccurrence = nextOccurrence) } - fun onErrorConsumed(consumed: AfternoteEditorErrorEvent) { - internalState.update { current -> - if (current.errorEvent == consumed) current.copy(errorEvent = null) else current - } - } - - // endregion - - /** - * 수신자 선택 화면이 고른 id 를 에디터로 돌려준다. - * - * Nav2 에서는 선택 화면이 **이전 백스택 엔트리**(에디터)의 `SavedStateHandle` 에 직접 썼다. - * Nav3 엔 "이전 엔트리" 개념이 없으므로, 두 화면이 이미 공유하는 이 ViewModel 을 통로로 - * 삼는다. 저장 위치는 같은 [SavedStateHandle] 이라 프로세스 재생성 성질도 그대로다. - */ - fun onReceiversSelected(receiverIds: List) { - // Bundle 이 그대로 담을 수 있는 LongArray 로 넘긴다 (#1426). - savedStateHandle[SELECTED_RECEIVER_IDS_KEY] = receiverIds.toLongArray() - } - - /** 남아 있는 선택 결과를 읽고 **지운다** — 같은 선택이 두 번 반영되지 않는다. */ - fun consumeSelectedReceiverIds(): List? = savedStateHandle.remove(SELECTED_RECEIVER_IDS_KEY)?.toList() - @AssistedFactory interface Factory { fun create(route: AfternoteRoute.EditorFlowRoute): AfternoteEditorViewModel @@ -859,7 +950,7 @@ class AfternoteEditorViewModel * [AfternoteFailure] 는 루트로 받아 `when` 을 exhaustive 하게 만든다 — 실패 유형이 늘면 여기가 * 컴파일 에러로 잡힌다. `else` 로 뭉개 두면 새 유형이 조용히 서버 오류로 흘러간다. */ -internal fun Throwable.toAfternoteEditorError(): AfternoteEditorError = +private fun Throwable.toAfternoteEditorError(): AfternoteEditorError = when (this) { is AfternoteFailure -> { when (this) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt index ccb4da7ef5..82b1e40676 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -110,14 +111,7 @@ internal fun EditorServiceSelectionSheet( ) { if (!visible) return val titleRes = type.serviceSelectionSheetTitleResOrNull() ?: return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - containerColor = AfternoteDesign.colors.white, - dragHandle = null, - ) { + val content: @Composable () -> Unit = { EditorServiceSelectionSheetContent( title = stringResource(titleRes), type = type, @@ -126,11 +120,25 @@ internal fun EditorServiceSelectionSheet( onServiceSelected = onServiceSelected, ) } + // Layoutlib Preview는 ModalBottomSheet의 별도 창을 캡처하지 못하므로 같은 본문을 직접 렌더한다. + if (LocalInspectionMode.current) { + content() + return + } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = AfternoteDesign.colors.white, + dragHandle = null, + ) { + content() + } } -/** Popup 윈도 밖에서도 Preview·Robolectric 검증이 가능한 시트 본문. */ +/** 서비스 선택 시트의 검색 입력과 목록. */ @Composable -internal fun EditorServiceSelectionSheetContent( +private fun EditorServiceSelectionSheetContent( title: String, type: AfternoteType, services: List, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt index ea20f34cf9..e155b25d5f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt @@ -1,7 +1,10 @@ package com.afternote.feature.afternote.presentation.editor.state import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.editor.AfternoteEditorSnapshot import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver @@ -98,13 +101,13 @@ data class AfternoteEditorErrorEvent( /** * 에디터 화면의 단일 UI 상태. * - * 일회성 신호(`pending*`)를 Channel 이 아니라 상태로 둔 건 configuration change·process death 뒤 - * 재구독에서도 마지막 신호가 살아남아야 해서다. non-null 이면 UI 가 처리 후 `on*Consumed()` 로 되돌린다. + * 일회성 신호(`pending*`)는 같은 ViewModel을 다시 구독할 때까지 유지한다. + * non-null 이면 UI가 처리 후 `Intent.ConsumeXxx`로 되돌린다. 프로세스 복원용 폼은 SavedStateHandle에 별도로 저장한다. * * 에디터 오류는 [errorEvent] 한 필드에서 종류와 발생 순서를 보존한다. 5xx 본문에 내부 SQL 이 섞여 올 수 있으므로 * 서버 raw 메시지는 상태에 싣지 않고, UI가 오류 종류를 안전한 로컬 문구로 변환한다. */ -data class AfternoteEditorUiState( +internal data class AfternoteEditorUiState( val form: EditorFormState = EditorFormState(), val authorReceivers: List = emptyList(), val isSaving: Boolean = false, @@ -123,9 +126,9 @@ data class AfternoteEditorUiState( val isPrefillFailed: Boolean = false, val savedId: Long? = null, val errorEvent: AfternoteEditorErrorEvent? = null, - /** 저장 성공 신호 — UI 가 nav 후 `onSaveSuccessConsumed` 로 reset. */ + /** 저장 성공 신호 — UI 가 nav 후 `ConsumeSaveSuccess` 로 reset. */ val pendingSaveSuccessId: Long? = null, - /** 장례식에 남길 영상 썸네일 업로드 완료 신호 — UI 파사드가 form 에 url 적용 후 `onThumbnailUploadedConsumed` 로 reset. */ + /** 장례식에 남길 영상 썸네일 업로드 완료 신호 — UI 파사드가 form 에 url 적용 후 `ConsumeThumbnailUploaded` 로 reset. */ val pendingThumbnailUrl: String? = null, /** * 추출부터 다시 돌리기 위한 키. 바뀌면 UI 가 프레임 추출을 재발화한다 (#1550). @@ -135,9 +138,13 @@ data class AfternoteEditorUiState( * 실패의 복구 경로는 영상을 처음부터 다시 고르는 것뿐이다. */ val memorialThumbnailRetryToken: Int = 0, - /** 수정 모드 prefill 데이터 — UI 파사드가 form 에 적용 후 `onPrefillApplied` 로 reset (skeleton 종료 동시). */ + /** 수정 모드 prefill 데이터 — UI 파사드가 form 에 적용 후 `ConsumePrefill` 로 reset (skeleton 종료 동시). */ val pendingPrefill: EditorFormPrefill? = null, -) { + /** 저장 시 기준과 오류 식별도 같은 reducer가 관리한다. */ + val originalType: AfternoteType? = null, + val updateBaseline: AfternoteEditorSnapshot? = null, + val errorOccurrence: Long = 0L, +) : UiState { val error: AfternoteEditorError? get() = errorEvent?.error } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowHost.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowHost.kt index ed984ae2e3..1bf914d10a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowHost.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteEditorFlowHost.kt @@ -9,6 +9,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.feature.afternote.presentation.editor.AfternoteEditorIntent import com.afternote.feature.afternote.presentation.editor.AfternoteEditorNavigation import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.memorial.AddSongViewModel @@ -50,7 +51,7 @@ internal fun AfternoteEditorFlowHost( AfternoteEditorFlowLocalNavActions( flowStack = flowStack, boundary = boundary, - onReceiversSelected = editorViewModel::onReceiversSelected, + onReceiversSelected = { receiverIds -> editorViewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(receiverIds)) }, onSaveSuccessNavigateHome = onSaveSuccessNavigateHome, ) } @@ -87,8 +88,10 @@ internal fun AfternoteEditorFlowHost( songs = editorUiState.form.memorialPlaylistSongs, onBackClick = actions::popBack, onNavigateToAddSongScreen = actions::navigateToAddSong, - onClearAllSongs = editorViewModel::clearMemorialPlaylistSongs, - onRemoveSongs = editorViewModel::removeMemorialPlaylistSongs, + onClearAllSongs = { editorViewModel.onIntent(AfternoteEditorIntent.ClearMemorialPlaylistSongs) }, + onRemoveSongs = { selectionKeys -> + editorViewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPlaylistSongs(selectionKeys)) + }, ) } @@ -96,7 +99,7 @@ internal fun AfternoteEditorFlowHost( val addSongViewModel: AddSongViewModel = hiltViewModel() AfternoteAddSongNavigation( onPopBackStack = actions::popBack, - onSongsAdded = editorViewModel::addMemorialPlaylistSongs, + onSongsAdded = { songs -> editorViewModel.onIntent(AfternoteEditorIntent.AddMemorialPlaylistSongs(songs)) }, viewModel = addSongViewModel, ) } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillSkeletonScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillSkeletonScreenshotTest.kt index d2769262ff..2df608e3ef 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillSkeletonScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorPrefillSkeletonScreenshotTest.kt @@ -1,6 +1,8 @@ package com.afternote.feature.afternote.presentation.editor +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType @@ -37,10 +39,16 @@ private fun EditorPrefillSkeletonScreenshotFixture(type: AfternoteType) { state.currentForm().copy( typeForm = AfternoteTypeForm.pristineFor(type), ) - EditorContent( + AfternoteEditorBody( state = state, form = form, - typeContent = {}, + onNavigateToMemorialPlaylist = {}, + onNavigateToSelectReceiver = {}, + onThumbnailBytesReady = {}, + onThumbnailExtractionFailed = {}, + thumbnailRetryToken = 0, + onCaptureFailed = {}, + snackbarHostState = remember { SnackbarHostState() }, isPrefillLoading = true, ) } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt index 65048ea255..df12945e2f 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetScreenshotTest.kt @@ -17,8 +17,9 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun editorServiceSelectionSheetScreenshot() { AfternoteTheme { - EditorServiceSelectionSheetContent( - title = "소셜 네트워크 서비스 선택", + EditorServiceSelectionSheet( + visible = true, + onDismissRequest = {}, type = AfternoteType.SOCIAL_NETWORK, services = AfternoteServiceCatalog.socialServices, searchQueryState = rememberTextFieldState(), @@ -32,8 +33,9 @@ internal fun editorServiceSelectionSheetScreenshot() { @Composable internal fun editorServiceSelectionSheetEmptySearchScreenshot() { AfternoteTheme { - EditorServiceSelectionSheetContent( - title = "갤러리 및 파일 서비스 선택", + EditorServiceSelectionSheet( + visible = true, + onDismissRequest = {}, type = AfternoteType.GALLERY_AND_FILES, services = AfternoteServiceCatalog.galleryServices, searchQueryState = rememberTextFieldState("없는 서비스"), diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 103020f356..44bb5bad1e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -53,6 +53,7 @@ import com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewMo import com.afternote.feature.afternote.presentation.detail.DetailContentUiModel import com.afternote.feature.afternote.presentation.detail.account.AccountDetailScreen import com.afternote.feature.afternote.presentation.editor.AfternoteEditorBody +import com.afternote.feature.afternote.presentation.editor.AfternoteEditorIntent import com.afternote.feature.afternote.presentation.editor.AfternoteEditorScreen import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia @@ -134,7 +135,7 @@ class AfternoteAuthorExtendedTest { .onNode(hasSetTextAction() and hasText("old@example.test")) .performTextReplacement("edited@example.test") composeRule.runOnIdle { - checkNotNull(editorViewModel).editProcessingMethod(1, "계정 보존") + checkNotNull(editorViewModel).onIntent(AfternoteEditorIntent.EditProcessingMethod(1, "계정 보존")) } composeRule.onNodeWithText("계정 보존").performScrollTo().assertIsDisplayed() val topBarRegister = @@ -192,7 +193,7 @@ class AfternoteAuthorExtendedTest { composeRule.runOnIdle { checkNotNull(editorState).dismissServiceSelectionSheet() } composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertDoesNotExist() - assertEquals("Instagram", viewModel.currentForm().selectedService) + assertEquals("Instagram", viewModel.uiState.value.form.selectedService) assertEquals("", checkNotNull(editorState).serviceSearchQueryState.text.toString()) composeRule.onNodeWithText("Instagram").performClick() @@ -202,7 +203,7 @@ class AfternoteAuthorExtendedTest { composeRule.onNodeWithText("소셜 네트워크 서비스 선택").assertDoesNotExist() composeRule.onNodeWithText("페이스북").assertIsDisplayed() - assertEquals("페이스북", viewModel.currentForm().selectedService) + assertEquals("페이스북", viewModel.uiState.value.form.selectedService) assertEquals("", checkNotNull(editorState).serviceSearchQueryState.text.toString()) } @@ -359,21 +360,27 @@ private fun AuthorEditorForUpdate( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val state = rememberAfternoteEditorState( - getCurrentForm = viewModel::currentForm, - setType = viewModel::setType, - setService = viewModel::setService, - setMemorialPhoto = viewModel::setMemorialPhoto, - removeMemorialPhoto = viewModel::removeMemorialPhoto, - setMemorialVideo = viewModel::setMemorialVideo, - removeMemorialVideo = viewModel::removeMemorialVideo, - addReceiverIfAbsent = viewModel::addReceiverIfAbsent, - applyPrefill = viewModel::applyPrefill, - setMemorialThumbnail = viewModel::setMemorialThumbnail, - deleteReceiver = viewModel::deleteReceiver, - replaceReceiversIfEmpty = viewModel::replaceReceiversIfEmpty, - addProcessingMethod = viewModel::addProcessingMethod, - deleteProcessingMethod = viewModel::deleteProcessingMethod, - editProcessingMethod = viewModel::editProcessingMethod, + getCurrentForm = { viewModel.uiState.value.form }, + setType = { type -> viewModel.onIntent(AfternoteEditorIntent.SetType(type)) }, + setService = { service -> viewModel.onIntent(AfternoteEditorIntent.SetService(service)) }, + setMemorialPhoto = { uri -> viewModel.onIntent(AfternoteEditorIntent.SetMemorialPhoto(uri)) }, + removeMemorialPhoto = { viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) }, + setMemorialVideo = { url -> viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(url)) }, + removeMemorialVideo = { viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) }, + addReceiverIfAbsent = { + receiverId, + name, + label, + -> + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(receiverId, name, label)) + }, + applyPrefill = { prefill -> viewModel.onIntent(AfternoteEditorIntent.ApplyPrefill(prefill)) }, + setMemorialThumbnail = { dataUrl -> viewModel.onIntent(AfternoteEditorIntent.SetMemorialThumbnail(dataUrl)) }, + deleteReceiver = { receiverId -> viewModel.onIntent(AfternoteEditorIntent.DeleteReceiver(receiverId)) }, + replaceReceiversIfEmpty = { receivers -> viewModel.onIntent(AfternoteEditorIntent.ReplaceReceiversIfEmpty(receivers)) }, + addProcessingMethod = { text -> viewModel.onIntent(AfternoteEditorIntent.AddProcessingMethod(text)) }, + deleteProcessingMethod = { localId -> viewModel.onIntent(AfternoteEditorIntent.DeleteProcessingMethod(localId)) }, + editProcessingMethod = { localId, newText -> viewModel.onIntent(AfternoteEditorIntent.EditProcessingMethod(localId, newText)) }, ) onStateReady(state) @@ -381,7 +388,7 @@ private fun AuthorEditorForUpdate( LaunchedEffect(pendingPrefill) { if (pendingPrefill != null) { state.applyFormPrefill(pendingPrefill) - viewModel.onPrefillConsumed() + viewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) } } @@ -400,10 +407,12 @@ private fun AuthorEditorForUpdate( password = state.passwordState.text.toString(), date = LocalDate.of(2026, 8, 22), ) - viewModel.saveAfternote( - payload = payload, - selectedReceiverIds = form.afternoteEditReceivers.map { it.id.toLong() }, - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = payload, + selectedReceiverIds = form.afternoteEditReceivers.map { it.id.toLong() }, + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) }, snackbarMessage = null, @@ -412,10 +421,10 @@ private fun AuthorEditorForUpdate( stringResource(it.reason.messageResId) }, onSnackbarMessageConsumed = { - errorEvent?.let(viewModel::onErrorConsumed) + errorEvent?.let({ consumed -> viewModel.onIntent(AfternoteEditorIntent.ConsumeError(consumed)) }) }, onValidationMessageConsumed = { - errorEvent?.let(viewModel::onErrorConsumed) + errorEvent?.let({ consumed -> viewModel.onIntent(AfternoteEditorIntent.ConsumeError(consumed)) }) }, content = { editorSnackbarHostState -> AfternoteEditorBody( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 9212800326..4240744f34 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -22,6 +22,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnai import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase +import com.afternote.feature.afternote.presentation.editor.AfternoteEditorIntent import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -56,21 +57,23 @@ class AfternoteAuthorImplementedCoverageTest { collectSaveState(viewModel) composeRule.runOnIdle { - viewModel.setType(AfternoteType.GALLERY_AND_FILES) - viewModel.saveAfternote( - payload = - RegisterAfternotePayload( - serviceName = "Google Drive", - date = "2026.08.22", - messageBlocks = - listOf( - EditorMessageTextBlock(" 사진 ", " 여행 사진을 보관해 줘 "), - EditorMessageTextBlock("", ""), - ), - processingMethods = listOf("가족에게 폴더 전달"), - ), - selectedReceiverIds = listOf(7L, 8L), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.GALLERY_AND_FILES)) + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = "Google Drive", + date = "2026.08.22", + messageBlocks = + listOf( + EditorMessageTextBlock(" 사진 ", " 여행 사진을 보관해 줘 "), + EditorMessageTextBlock("", ""), + ), + processingMethods = listOf("가족에게 폴더 전달"), + ), + selectedReceiverIds = listOf(7L, 8L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) } @@ -101,19 +104,21 @@ class AfternoteAuthorImplementedCoverageTest { collectSaveState(viewModel) composeRule.runOnIdle { - viewModel.setType(AfternoteType.BUSINESS) - viewModel.saveAfternote( - payload = - RegisterAfternotePayload( - serviceName = "회사 그룹웨어", - date = "2026.08.22", - accountId = "employee@example.test", - password = "business-password", - messageBlocks = listOf(EditorMessageTextBlock("인수인계", "팀장에게 전달해 줘")), - processingMethods = listOf("계정 인계"), - ), - selectedReceiverIds = listOf(8L), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.BUSINESS)) + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = "회사 그룹웨어", + date = "2026.08.22", + accountId = "employee@example.test", + password = "business-password", + messageBlocks = listOf(EditorMessageTextBlock("인수인계", "팀장에게 전달해 줘")), + processingMethods = listOf("계정 인계"), + ), + selectedReceiverIds = listOf(8L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) } @@ -159,27 +164,30 @@ class AfternoteAuthorImplementedCoverageTest { ) composeRule.runOnIdle { - viewModel.setType(AfternoteType.MEMORIAL) - viewModel.addMemorialPlaylistSongs(songs) - viewModel.saveAfternote( - payload = - RegisterAfternotePayload( - serviceName = "추억 노트", - date = "2026.08.22", - ), - selectedReceiverIds = listOf(7L), - memorialMedia = - SaveAfternoteMemorialMedia( - memorialVideo = - EditableMemorialVideo - .empty() - .withSelection("content://videos/farewell") - .withSelectionThumbnail("https://cdn.test/thumbnail.jpg"), - memorialPhoto = - EditableMemorialPhoto - .fromPersisted("https://cdn.test/old-photo.jpg") - .withSelection("content://photos/new-portrait"), - ), + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.MEMORIAL)) + viewModel.onIntent(AfternoteEditorIntent.AddMemorialPlaylistSongs(songs)) + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = "추억 노트", + date = "2026.08.22", + ), + selectedReceiverIds = listOf(7L), + memorialMedia = + SaveAfternoteMemorialMedia( + memorialVideo = + EditableMemorialVideo + .empty() + .withSelection("content://videos/farewell") + .withSelectionThumbnail("https://cdn.test/thumbnail.jpg"), + memorialPhoto = + EditableMemorialPhoto + .fromPersisted( + "https://cdn.test/old-photo.jpg", + ).withSelection("content://photos/new-portrait"), + ), + ), ) } @@ -234,16 +242,18 @@ class AfternoteAuthorImplementedCoverageTest { composeRule.setContent { AfternoteTheme {} } composeRule.runOnIdle { - first.setType(AfternoteType.MEMORIAL) - first.setMemorialVideo("content://videos/farewell") - first.setMemorialPhoto("content://photos/portrait") - first.setMemorialThumbnail("https://cdn.test/thumbnail.jpg") - first.addMemorialPlaylistSongs( - listOf(Song("91", "첫 번째 노래", "가수 A", "https://cdn.test/cover.jpg")), + first.onIntent(AfternoteEditorIntent.SetType(AfternoteType.MEMORIAL)) + first.onIntent(AfternoteEditorIntent.SetMemorialVideo("content://videos/farewell")) + first.onIntent(AfternoteEditorIntent.SetMemorialPhoto("content://photos/portrait")) + first.onIntent(AfternoteEditorIntent.SetMemorialThumbnail("https://cdn.test/thumbnail.jpg")) + first.onIntent( + AfternoteEditorIntent.AddMemorialPlaylistSongs( + listOf(Song("91", "첫 번째 노래", "가수 A", "https://cdn.test/cover.jpg")), + ), ) } - val restored = implementedCoverageViewModel(implementedCoverageRepository(), handle).currentForm() + val restored = implementedCoverageViewModel(implementedCoverageRepository(), handle).uiState.value.form assertEquals(AfternoteType.MEMORIAL, restored.selectedType) assertEquals( MemorialVideoAttachment( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index 4c49b8b7c5..bb23b67937 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnai import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase +import com.afternote.feature.afternote.presentation.editor.AfternoteEditorIntent import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock @@ -48,11 +49,13 @@ class AfternoteAuthorTest { } composeRule.runOnIdle { - viewModel.setType(AfternoteType.SOCIAL_NETWORK) - viewModel.saveAfternote( - payload = validSocialPayload(), - selectedReceiverIds = emptyList(), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.SOCIAL_NETWORK)) + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = validSocialPayload(), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.savedId == 41L } @@ -82,15 +85,15 @@ class AfternoteAuthorTest { val payload = validSocialPayload() composeRule.runOnIdle { - viewModel.setType(AfternoteType.SOCIAL_NETWORK) - viewModel.saveAfternote(payload, listOf(7L), SaveAfternoteMemorialMedia()) + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.SOCIAL_NETWORK)) + viewModel.onIntent(AfternoteEditorIntent.Save(payload, listOf(7L), SaveAfternoteMemorialMedia())) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.error != null } assertNull(viewModel.uiState.value.savedId) composeRule.runOnIdle { - viewModel.setType(AfternoteType.SOCIAL_NETWORK) - viewModel.saveAfternote(payload, listOf(7L), SaveAfternoteMemorialMedia()) + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.SOCIAL_NETWORK)) + viewModel.onIntent(AfternoteEditorIntent.Save(payload, listOf(7L), SaveAfternoteMemorialMedia())) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.savedId == 42L } @@ -109,13 +112,13 @@ class AfternoteAuthorTest { val first = viewModel(FakeAfternoteRepository.strict(), handle) composeRule.setContent { AfternoteTheme {} } composeRule.runOnIdle { - first.setType(AfternoteType.GALLERY_AND_FILES) - first.setService("Google Photos") - first.addReceiverIfAbsent(7L, "김수신", "가족") - first.addProcessingMethod("전체 파일 전달") + first.onIntent(AfternoteEditorIntent.SetType(AfternoteType.GALLERY_AND_FILES)) + first.onIntent(AfternoteEditorIntent.SetService("Google Photos")) + first.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(7L, "김수신", "가족")) + first.onIntent(AfternoteEditorIntent.AddProcessingMethod("전체 파일 전달")) } - val restored = viewModel(FakeAfternoteRepository.strict(), handle).currentForm() + val restored = viewModel(FakeAfternoteRepository.strict(), handle).uiState.value.form assertEquals(AfternoteType.GALLERY_AND_FILES, restored.selectedType) assertEquals("Google Photos", restored.selectedService) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorErrorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorErrorTest.kt index c655d9cf38..883f97345e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorErrorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorErrorTest.kt @@ -1,45 +1,103 @@ package com.afternote.feature.afternote.presentation.editor +import androidx.lifecycle.SavedStateHandle +import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase +import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.io.IOException +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class AfternoteEditorErrorTest { - @Test - fun `네트워크 단절은 Network로 변환`() { - val failure = AfternoteFailure.NetworkUnavailable(IOException("timeout")) + private val dispatcher = StandardTestDispatcher() - assertEquals(AfternoteEditorError.Network, failure.toAfternoteEditorError()) - } + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() @Test - fun `저장 중 영상 업로드 실패는 SAVE_MEDIA Upload로 변환`() { - val failure = AfternoteFailure.MediaSave(MediaKind.VIDEO, IOException("upload failed")) + fun `네트워크 단절은 저장 오류 Network로 표시한다`() = + assertSaveError(AfternoteFailure.NetworkUnavailable(IOException("timeout")), AfternoteEditorError.Network) - assertEquals( + @Test + fun `영상 업로드 실패는 저장 미디어 오류로 표시한다`() = + assertSaveError( + AfternoteFailure.MediaSave(MediaKind.VIDEO, IOException("upload failed")), AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA), - failure.toAfternoteEditorError(), ) - } @Test - fun `저장 중 사진 업로드 실패는 SAVE_MEDIA Upload로 변환`() { - val failure = AfternoteFailure.MediaSave(MediaKind.PHOTO, IOException("upload failed")) - - assertEquals( + fun `사진 업로드 실패는 저장 미디어 오류로 표시한다`() = + assertSaveError( + AfternoteFailure.MediaSave(MediaKind.PHOTO, IOException("upload failed")), AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA), - failure.toAfternoteEditorError(), ) - } @Test - fun `그 밖의 저장 실패는 Server로 변환`() { - assertEquals( - AfternoteEditorError.Server, - IllegalStateException("failed").toAfternoteEditorError(), + fun `그 밖의 저장 실패는 서버 오류로 표시한다`() = assertSaveError(IllegalStateException("failed"), AfternoteEditorError.Server) + + private fun assertSaveError( + failure: Throwable, + expected: AfternoteEditorError, + ) = runTest(dispatcher) { + val repository = FakeAfternoteRepository.strict().apply { onCreateSocial = { Result.failure(failure) } } + val viewModel = + AfternoteEditorViewModel( + route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), + savedStateHandle = SavedStateHandle(), + userReceiverRepository = afternoteAuthorUserReceiverRepository(), + afternoteRepository = repository, + memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("unused") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { _, _ -> Result.success(null) }, + ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), + errorReporter = NoopAuthorErrorReporter, + ) + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = "인스타그램", + date = "2026.09.08", + accountId = "account", + password = "password", + processingMethods = listOf("계정 삭제"), + ), + selectedReceiverIds = listOf(1L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) + runCurrent() + assertEquals(expected, viewModel.uiState.value.error) + assertEquals(false, viewModel.uiState.value.isSaving) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index 61ac63cd04..b34a2ee65e 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -60,107 +60,121 @@ class AfternoteEditorMemorialMediaRemoveTest { @Test fun `신규 영상을 삭제하면 파생 썸네일도 함께 비운다`() { val viewModel = viewModel(memorialSavedStateHandle()) - viewModel.setMemorialVideo("content://videos/farewell") - viewModel.setMemorialThumbnail("https://cdn.test/thumbnail.jpg") + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo("content://videos/farewell")) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialThumbnail("https://cdn.test/thumbnail.jpg")) - viewModel.removeMemorialVideo() + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) - assertNull(viewModel.currentForm().displayedMemorialVideo) + assertNull(viewModel.uiState.value.form.displayedMemorialVideo) } @Test fun `사진 삭제는 로컬 픽과 서버 원본을 함께 비운다`() { val viewModel = viewModel(memorialSavedStateHandle()) - viewModel.applyPrefill(memorialPrefill(photoUrl = "https://cdn.test/portrait.jpg")) - viewModel.setMemorialPhoto("content://photos/replacement") - assertEquals("content://photos/replacement", viewModel.currentForm().displayMemorialPhotoUri()) + viewModel.onIntent(AfternoteEditorIntent.ApplyPrefill(memorialPrefill(photoUrl = "https://cdn.test/portrait.jpg"))) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialPhoto("content://photos/replacement")) + assertEquals( + "content://photos/replacement", + viewModel.uiState.value.form + .displayMemorialPhotoUri(), + ) - viewModel.removeMemorialPhoto() + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) assertNull( - viewModel - .currentForm() - .memorialPhoto + viewModel.uiState.value.form.memorialPhoto ?.toSnapshot() ?.selection, ) assertNull( - viewModel - .currentForm() - .memorialPhoto + viewModel.uiState.value.form.memorialPhoto ?.toSnapshot() ?.persisted, ) - assertNull(viewModel.currentForm().displayMemorialPhotoUri()) + assertNull( + viewModel.uiState.value.form + .displayMemorialPhotoUri(), + ) } @Test fun `영상 삭제는 로컬 픽과 서버 원본과 썸네일을 함께 비운다`() { val viewModel = viewModel(memorialSavedStateHandle()) - viewModel.applyPrefill( - memorialPrefill( - videoUrl = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/server-thumbnail.jpg", + viewModel.onIntent( + AfternoteEditorIntent.ApplyPrefill( + memorialPrefill( + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/server-thumbnail.jpg", + ), ), ) - viewModel.setMemorialVideo("content://videos/replacement") - viewModel.setMemorialThumbnail("https://cdn.test/local-thumbnail.jpg") + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo("content://videos/replacement")) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialThumbnail("https://cdn.test/local-thumbnail.jpg")) - viewModel.removeMemorialVideo() + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) - assertNull(viewModel.currentForm().displayedMemorialVideo) - assertFalse(viewModel.currentForm().canRemoveMemorialVideo) + assertNull(viewModel.uiState.value.form.displayedMemorialVideo) + assertFalse(viewModel.uiState.value.form.canRemoveMemorialVideo) } @Test fun `서버 미디어만 있어도 사진과 영상을 삭제할 수 있다`() { val viewModel = viewModel(memorialSavedStateHandle()) - viewModel.applyPrefill( - memorialPrefill( - photoUrl = "https://cdn.test/portrait.jpg", - videoUrl = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/thumbnail.jpg", + viewModel.onIntent( + AfternoteEditorIntent.ApplyPrefill( + memorialPrefill( + photoUrl = "https://cdn.test/portrait.jpg", + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/thumbnail.jpg", + ), ), ) - viewModel.removeMemorialPhoto() - viewModel.removeMemorialVideo() + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) assertNull( - viewModel - .currentForm() - .memorialPhoto + viewModel.uiState.value.form.memorialPhoto ?.toSnapshot() ?.selection, ) assertNull( - viewModel - .currentForm() - .memorialPhoto + viewModel.uiState.value.form.memorialPhoto ?.toSnapshot() ?.persisted, ) - assertNull(viewModel.currentForm().displayMemorialPhotoUri()) - assertNull(viewModel.currentForm().displayedMemorialVideo?.url) - assertNull(viewModel.currentForm().displayedMemorialVideo?.thumbnailUrl) + assertNull( + viewModel.uiState.value.form + .displayMemorialPhotoUri(), + ) + assertNull( + viewModel.uiState.value.form.displayedMemorialVideo + ?.url, + ) + assertNull( + viewModel.uiState.value.form.displayedMemorialVideo + ?.thumbnailUrl, + ) } @Test fun `삭제한 서버 미디어 스냅샷은 상세 재조회 없는 복원에서도 비어 있다`() { val handle = memorialSavedStateHandle() val first = viewModel(handle) - first.applyPrefill( - memorialPrefill( - photoUrl = "https://cdn.test/portrait.jpg", - videoUrl = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/thumbnail.jpg", - playlistSongs = listOf(Song("detail:0", "노래", "가수")), + first.onIntent( + AfternoteEditorIntent.ApplyPrefill( + memorialPrefill( + photoUrl = "https://cdn.test/portrait.jpg", + videoUrl = "https://cdn.test/farewell.mp4", + thumbnailUrl = "https://cdn.test/thumbnail.jpg", + playlistSongs = listOf(Song("detail:0", "노래", "가수")), + ), ), ) - first.removeMemorialVideo() - first.removeMemorialPhoto() + first.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) + first.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) - val restored = viewModel(handle).currentForm() + val restored = viewModel(handle).uiState.value.form assertNull(restored.displayedMemorialVideo) assertNull(restored.memorialPhoto?.toSnapshot()?.selection) @@ -196,8 +210,8 @@ class AfternoteEditorMemorialMediaRemoveTest { ) { val handle = memorialSavedStateHandle() val original = viewModel(handle) - original.applyPrefill(memorialPrefill(photoUrl = persisted)) - if (selection != null) original.setMemorialPhoto(selection) + original.onIntent(AfternoteEditorIntent.ApplyPrefill(memorialPrefill(photoUrl = persisted))) + if (selection != null) original.onIntent(AfternoteEditorIntent.SetMemorialPhoto(selection)) val encoded = requireNotNull(handle.get("editor_form_snapshot_v4")) val fields = Json.parseToJsonElement(encoded).jsonObject assertEquals(persisted?.let(::JsonPrimitive) ?: JsonNull, fields["memorialPhotoUrl"]) @@ -205,12 +219,12 @@ class AfternoteEditorMemorialMediaRemoveTest { // 같은 진입 경로의 새 SavedStateHandle로 실제 직렬화된 스냅샷만 복사한다. val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v4", encoded) }) - val photo = requireNotNull(restored.currentForm().memorialPhoto) - assertEquals(original.currentForm(), restored.currentForm()) + val photo = requireNotNull(restored.uiState.value.form.memorialPhoto) + assertEquals(original.uiState.value.form, restored.uiState.value.form) assertEquals(selection ?: persisted, photo.displayed) assertEquals(expectedInput, photo.toMediaInput()) - restored.removeMemorialPhoto() - assertEquals(MediaInput.None, requireNotNull(restored.currentForm().memorialPhoto).toMediaInput()) + restored.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) + assertEquals(MediaInput.None, requireNotNull(restored.uiState.value.form.memorialPhoto).toMediaInput()) } private fun memorialSavedStateHandle(): SavedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.MEMORIAL)) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt index 5d3e9809cd..c50d06f37f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -70,10 +70,12 @@ class AfternoteEditorMissingBaselineTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - viewModel.saveAfternote( - payload = RegisterAfternotePayload(serviceName = "구글 포토", date = "2026-08-30"), - selectedReceiverIds = emptyList(), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = RegisterAfternotePayload(serviceName = "구글 포토", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() @@ -97,13 +99,15 @@ class AfternoteEditorMissingBaselineTest { runCurrent() // 화면이 prefill 을 폼에 반영했다는 통보. 이걸 빼면 저장이 «아직 읽는 중» // ([AfternoteEditorError.PrefillNotReady]) 으로 막혀 기준 판정까지 가지 못한다 (#705). - viewModel.onPrefillConsumed() + viewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) runCurrent() - viewModel.saveAfternote( - payload = RegisterAfternotePayload(serviceName = "새 제목", date = "2026-08-30"), - selectedReceiverIds = emptyList(), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = RegisterAfternotePayload(serviceName = "새 제목", date = "2026-08-30"), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt index ae169570ee..3781627949 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -12,17 +12,21 @@ import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMedi import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -59,16 +63,19 @@ class AfternoteEditorMultiReceiverSelectionTest { val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) runCurrent() - viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID, FRIEND_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(DAUGHTER_ID, FRIEND_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) assertEquals( listOf("김수신", "박친구"), - viewModel.currentForm().afternoteEditReceivers.map { it.name }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.name }, ) } @@ -76,15 +83,17 @@ class AfternoteEditorMultiReceiverSelectionTest { fun `이미 폼에 있는 수신자를 다시 확정해도 중복으로 쌓이지 않는다`() = runTest(dispatcher) { val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) - viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸")) runCurrent() - viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID, FRIEND_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(DAUGHTER_ID, FRIEND_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) } @@ -92,16 +101,18 @@ class AfternoteEditorMultiReceiverSelectionTest { fun `선택에서 빠진 기존 수신자는 폼에서도 빠진다`() = runTest(dispatcher) { val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) - viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") - viewModel.addReceiverIfAbsent(FRIEND_ID, "박친구", "친구") + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸")) + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(FRIEND_ID, "박친구", "친구")) runCurrent() - viewModel.applySelectedReceivers(listOf(FRIEND_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(FRIEND_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(FRIEND_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) } @@ -110,15 +121,17 @@ class AfternoteEditorMultiReceiverSelectionTest { runTest(dispatcher) { val repository = repositoryWith(DAUGHTER) val viewModel = viewModel(repository) - viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸")) runCurrent() - viewModel.applySelectedReceivers(listOf(DAUGHTER_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(DAUGHTER_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) assertEquals("폼이 이미 들고 있는 표시값을 두고 재조회할 이유가 없다", 0, repository.getReceiversCalls) } @@ -130,12 +143,14 @@ class AfternoteEditorMultiReceiverSelectionTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, UNKNOWN_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID, UNKNOWN_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(DAUGHTER_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) assertEquals( AfternoteEditorError.ReceiverSelectionUnavailable, @@ -148,27 +163,121 @@ class AfternoteEditorMultiReceiverSelectionTest { runTest(dispatcher) { val viewModel = viewModel(repositoryWith(DAUGHTER, FRIEND)) backgroundScope.launch { viewModel.uiState.collect {} } - viewModel.addReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸") - viewModel.addReceiverIfAbsent(FRIEND_ID, "박친구", "친구") + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(DAUGHTER_ID, "김수신", "딸")) + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(FRIEND_ID, "박친구", "친구")) runCurrent() - viewModel.applySelectedReceivers(listOf(DAUGHTER_ID, FRIEND_ID)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID, FRIEND_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() assertEquals( listOf(DAUGHTER_ID, FRIEND_ID), - viewModel.currentForm().afternoteEditReceivers.map { it.id }, + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, ) assertNull("정상 반영에는 오류를 세우지 않는다", viewModel.uiState.value.error) } + @Test + fun `선택 결과는 편집기 복귀에서 한 번만 폼에 적용한다`() = + runTest(dispatcher) { + val viewModel = viewModel(repositoryWith(DAUGHTER)) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID))) + runCurrent() + assertTrue( + viewModel.uiState.value.form.afternoteEditReceivers + .isEmpty(), + ) + + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) + runCurrent() + assertEquals( + listOf(DAUGHTER_ID), + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, + ) + + viewModel.onIntent(AfternoteEditorIntent.DeleteReceiver(DAUGHTER_ID)) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) + runCurrent() + assertTrue( + viewModel.uiState.value.form.afternoteEditReceivers + .isEmpty(), + ) + } + + @Test + fun `확정된 수신자 선택은 흐름 복원 뒤에도 한 번 적용한다`() = + runTest(dispatcher) { + val handle = SavedStateHandle() + val first = viewModel(repositoryWith(DAUGHTER), handle) + first.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID))) + + val restored = viewModel(repositoryWith(DAUGHTER), handle) + restored.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) + runCurrent() + assertEquals( + listOf(DAUGHTER_ID), + restored.uiState.value.form.afternoteEditReceivers + .map { it.id }, + ) + } + + @Test + fun `취소 뒤 늦게 도착한 이전 조회는 최신 선택과 수신자 목록을 덮지 않는다`() = + runTest(dispatcher) { + val previousLookup = CompletableDeferred>() + var calls = 0 + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceivers = { + if (calls++ == 0) { + withContext(NonCancellable) { previousLookup.await() } + } else { + listOf(FRIEND) + } + } + } + val viewModel = viewModel(repository) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(DAUGHTER_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) + runCurrent() + + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(FRIEND_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) + runCurrent() + assertEquals( + listOf(FRIEND_ID), + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, + ) + + previousLookup.complete(listOf(DAUGHTER)) + runCurrent() + assertEquals( + listOf(FRIEND_ID), + viewModel.uiState.value.form.afternoteEditReceivers + .map { it.id }, + ) + assertEquals( + listOf(FRIEND_ID), + viewModel.uiState.value.authorReceivers + .map { it.id }, + ) + assertNull(viewModel.uiState.value.error) + } + private fun repositoryWith(vararg receivers: Receiver): FakeUserReceiverRepository = FakeUserReceiverRepository.strict().apply { onGetReceivers = { receivers.toList() } } - private fun viewModel(userRepository: FakeUserReceiverRepository): AfternoteEditorViewModel = + private fun viewModel( + userRepository: FakeUserReceiverRepository, + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): AfternoteEditorViewModel = AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), - savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)), + savedStateHandle = savedStateHandle, userReceiverRepository = userRepository, afternoteRepository = unusedProxy(), memorialThumbnailUploadRepository = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt index 511229516f..e7de690dfd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -91,10 +91,12 @@ class AfternoteEditorPrefillFailureTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - viewModel.saveAfternote( - payload = SAVE_PAYLOAD, - selectedReceiverIds = listOf(7L), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() @@ -118,7 +120,7 @@ class AfternoteEditorPrefillFailureTest { assertTrue(viewModel.uiState.value.isPrefillFailed) shouldFail = false - viewModel.retryPrefill() + viewModel.onIntent(AfternoteEditorIntent.RetryPrefill) runCurrent() val state = viewModel.uiState.value @@ -143,17 +145,19 @@ class AfternoteEditorPrefillFailureTest { runCurrent() shouldFail = false - viewModel.retryPrefill() + viewModel.onIntent(AfternoteEditorIntent.RetryPrefill) runCurrent() // 화면이 pendingPrefill 을 폼·TextFieldState 에 실은 뒤 통보하는 단계. 이걸 거쳐야 // isPrefillLoading 이 내려간다 — 저장 가드가 «읽는 중» 도 막으므로 (#705) 여기서 // 생략하면 폼이 아직 비어 있는 상태의 저장이 되어 정상 경로가 아니다. - viewModel.onPrefillConsumed() + viewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) runCurrent() - viewModel.saveAfternote( - payload = SAVE_PAYLOAD, - selectedReceiverIds = listOf(7L), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() @@ -195,10 +199,12 @@ class AfternoteEditorPrefillFailureTest { assertTrue("전제: 아직 prefill 을 읽는 중이어야 한다", viewModel.uiState.value.isPrefillLoading) assertFalse("이 경로는 실패가 아니다", viewModel.uiState.value.isPrefillFailed) - viewModel.saveAfternote( - payload = SAVE_PAYLOAD, - selectedReceiverIds = listOf(7L), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = SAVE_PAYLOAD, + selectedReceiverIds = listOf(7L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() @@ -262,7 +268,7 @@ class AfternoteEditorPrefillFailureTest { runCurrent() assertFalse("전제: 신규 작성은 prefill 을 돌리지 않는다", viewModel.uiState.value.isPrefillLoading) - viewModel.retryPrefill() + viewModel.onIntent(AfternoteEditorIntent.RetryPrefill) runCurrent() assertFalse("재시도가 신규 작성에서 skeleton 을 세우면 안 된다", viewModel.uiState.value.isPrefillLoading) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 483a752428..5caaa9dcb9 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -79,7 +79,7 @@ class AfternoteEditorProcessDeathPrefillTest { assertEquals( "복원된 폼은 사용자가 고친 제목을 그대로 들고 있다", EDITED_SERVICE, - viewModel.currentForm().selectedService, + viewModel.uiState.value.form.selectedService, ) assertNull( "재조회가 프리필을 발행하면 화면이 그것을 실어 사용자의 편집이 사라진다", @@ -110,15 +110,17 @@ class AfternoteEditorProcessDeathPrefillTest { ) // 화면이 복원된 폼 값을 그대로 담아 보내는 저장이다. - viewModel.saveAfternote( - payload = - RegisterAfternotePayload( - serviceName = EDITED_SERVICE, - date = "2026-08-30", - processingMethods = listOf(SERVER_PROCESSING_METHOD), - ), - selectedReceiverIds = emptyList(), - memorialMedia = SaveAfternoteMemorialMedia(), + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = EDITED_SERVICE, + date = "2026-08-30", + processingMethods = listOf(SERVER_PROCESSING_METHOD), + ), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) runCurrent() diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index 0aed91e3df..11d871bc82 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -49,30 +49,55 @@ class AfternoteEditorProcessingMethodDefaultsTest { val viewModel = viewModel(savedStateHandle) val defaults = listOf("게시물 내리기", "추모 게시물 올리기") - viewModel.initializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, defaults) + viewModel.onIntent(AfternoteEditorIntent.InitializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, defaults)) - assertEquals(listOf(1, 2), viewModel.currentForm().processingMethods.map { it.localId }) - assertEquals(defaults, viewModel.currentForm().processingMethods.map { it.text }) + assertEquals( + listOf(1, 2), + viewModel.uiState.value.form.processingMethods + .map { it.localId }, + ) + assertEquals( + defaults, + viewModel.uiState.value.form.processingMethods + .map { it.text }, + ) - viewModel.currentForm().processingMethods.forEach { viewModel.deleteProcessingMethod(it.localId) } - viewModel.initializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, defaults) + viewModel.uiState.value.form.processingMethods.forEach { + viewModel.onIntent( + AfternoteEditorIntent.DeleteProcessingMethod(it.localId), + ) + } + viewModel.onIntent(AfternoteEditorIntent.InitializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, defaults)) - assertTrue(viewModel.currentForm().processingMethods.isEmpty()) - assertTrue(viewModel(savedStateHandle).currentForm().processingMethods.isEmpty()) + assertTrue( + viewModel.uiState.value.form.processingMethods + .isEmpty(), + ) + assertTrue( + viewModel(savedStateHandle) + .uiState.value.form.processingMethods + .isEmpty(), + ) } @Test fun `카테고리를 바꾸면 새 카테고리 추천을 채운다`() { val viewModel = viewModel(SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK))) - viewModel.initializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, listOf("계정 삭제")) + viewModel.onIntent(AfternoteEditorIntent.InitializeProcessingMethodDefaults(AfternoteType.SOCIAL_NETWORK, listOf("계정 삭제"))) + + viewModel.onIntent(AfternoteEditorIntent.SetType(AfternoteType.GALLERY_AND_FILES)) + viewModel.onIntent( + AfternoteEditorIntent.InitializeProcessingMethodDefaults( + AfternoteType.GALLERY_AND_FILES, + listOf("폴더 전송", "폴더 삭제"), + ), + ) - viewModel.setType(AfternoteType.GALLERY_AND_FILES) - viewModel.initializeProcessingMethodDefaults( - AfternoteType.GALLERY_AND_FILES, + assertEquals( listOf("폴더 전송", "폴더 삭제"), + viewModel.uiState.value.form.processingMethods + .map { it.text }, ) - - assertEquals(listOf("폴더 전송", "폴더 삭제"), viewModel.currentForm().processingMethods.map { it.text }) } @Test diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index e9b0b78d75..eb136bf0be 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -62,8 +62,12 @@ class AfternoteEditorReceiverSelectionRecoveryTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - val resolved = viewModel.resolveSelectedReceiver(RECEIVER_ID) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(RECEIVER_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() + val resolved = + viewModel.uiState.value.form.afternoteEditReceivers + .singleOrNull() assertNull("해석하지 못한 선택은 폼에 넣을 값이 없다", resolved) assertEquals( @@ -80,8 +84,12 @@ class AfternoteEditorReceiverSelectionRecoveryTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - val resolved = viewModel.resolveSelectedReceiver(RECEIVER_ID) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(RECEIVER_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() + val resolved = + viewModel.uiState.value.form.afternoteEditReceivers + .singleOrNull() assertEquals("김수신", resolved?.name) assertEquals("딸", resolved?.label) @@ -94,11 +102,15 @@ class AfternoteEditorReceiverSelectionRecoveryTest { val repository = repositoryWith { listOf(RECEIVER) } val viewModel = viewModel(repository) backgroundScope.launch { viewModel.uiState.collect {} } - viewModel.refreshAuthorReceivers() + viewModel.onIntent(AfternoteEditorIntent.RefreshAuthorReceivers) runCurrent() - val resolved = viewModel.resolveSelectedReceiver(RECEIVER_ID) + viewModel.onIntent(AfternoteEditorIntent.ReceiversSelected(listOf(RECEIVER_ID))) + viewModel.onIntent(AfternoteEditorIntent.ApplyPendingReceiverSelection) runCurrent() + val resolved = + viewModel.uiState.value.form.afternoteEditReceivers + .singleOrNull() assertEquals(RECEIVER_ID, resolved?.id) assertEquals("캐시 적중이면 추가 조회가 없어야 한다", 1, repository.getReceiversCalls) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index 878a3a96cb..0995c51edc 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -30,13 +30,13 @@ class AfternoteEditorReceiverSnapshotTest { val savedStateHandle = SavedStateHandle(mapOf("initialType" to AfternoteType.SOCIAL_NETWORK)) val viewModel = viewModel(savedStateHandle) - viewModel.addReceiverIfAbsent(Long.MAX_VALUE, "김수신", "딸") + viewModel.onIntent(AfternoteEditorIntent.AddReceiverIfAbsent(Long.MAX_VALUE, "김수신", "딸")) val raw = requireNotNull(savedStateHandle.get(SNAPSHOT_KEY)) assertTrue(raw.contains("\"id\":${Long.MAX_VALUE}")) val restoredId = viewModel(savedStateHandle) - .currentForm() + .uiState.value.form .afternoteEditReceivers .single() .id @@ -79,15 +79,15 @@ class AfternoteEditorReceiverSnapshotTest { ) val viewModel = viewModel(savedStateHandle) - assertEquals(selection, viewModel.currentForm().displayedMemorialVideo) - assertTrue(viewModel.currentForm().canRemoveMemorialVideo) + assertEquals(selection, viewModel.uiState.value.form.displayedMemorialVideo) + assertTrue(viewModel.uiState.value.form.canRemoveMemorialVideo) - viewModel.setMemorialThumbnail("https://cdn.test/round-trip-thumb.jpg") + viewModel.onIntent(AfternoteEditorIntent.SetMemorialThumbnail("https://cdn.test/round-trip-thumb.jpg")) val roundTrippedRaw = requireNotNull(savedStateHandle.get(SNAPSHOT_KEY)) assertTrue(roundTrippedRaw.contains("\"memorialVideo\"")) val restoredViewModel = viewModel(savedStateHandle) - val roundTripped = restoredViewModel.currentForm() + val roundTripped = restoredViewModel.uiState.value.form assertEquals( selection.copy(thumbnailUrl = "https://cdn.test/round-trip-thumb.jpg"), roundTripped.displayedMemorialVideo, @@ -95,12 +95,16 @@ class AfternoteEditorReceiverSnapshotTest { assertEquals(MediaInput.Local(selection.url), roundTripped.memorialVideo?.toMediaInput()) assertTrue(roundTripped.canRemoveMemorialVideo) - restoredViewModel.removeMemorialVideo() + restoredViewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) // 삭제는 교체분과 서버 원본을 함께 비운다 — 저장 시 명시적 null 로 나간다(#1597). - assertNull(restoredViewModel.currentForm().displayedMemorialVideo) - assertEquals(MediaInput.None, restoredViewModel.currentForm().memorialVideo?.toMediaInput()) - assertFalse(restoredViewModel.currentForm().canRemoveMemorialVideo) + assertNull(restoredViewModel.uiState.value.form.displayedMemorialVideo) + assertEquals( + MediaInput.None, + restoredViewModel.uiState.value.form.memorialVideo + ?.toMediaInput(), + ) + assertFalse(restoredViewModel.uiState.value.form.canRemoveMemorialVideo) } private fun viewModel(savedStateHandle: SavedStateHandle): AfternoteEditorViewModel = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index 9330705413..d2750916b6 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -67,17 +67,19 @@ class AfternoteEditorServerMediaDeleteSaveTest { val first = viewModel(repository) collectState(first) applyLoadedPrefill(first) - assertServerMediaAndSongs(first.currentForm()) + assertServerMediaAndSongs(first.uiState.value.form) - first.removeMemorialPhoto() - first.removeMemorialVideo() - val deletedForm = first.currentForm() + first.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) + first.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) + val deletedForm = first.uiState.value.form assertDeletedMediaAndSongs(deletedForm) - first.saveAfternote( - payload = validMemorialPayload(), - selectedReceiverIds = emptyList(), - memorialMedia = deletedForm.fullMemorialMediaForSave(), + first.onIntent( + AfternoteEditorIntent.Save( + payload = validMemorialPayload(), + selectedReceiverIds = emptyList(), + memorialMedia = deletedForm.fullMemorialMediaForSave(), + ), ) advanceUntilIdle() @@ -97,7 +99,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { collectState(reentered) applyLoadedPrefill(reentered) - assertDeletedMediaAndSongs(reentered.currentForm()) + assertDeletedMediaAndSongs(reentered.uiState.value.form) } @Test @@ -117,15 +119,15 @@ class AfternoteEditorServerMediaDeleteSaveTest { val viewModel = viewModel(repository) collectState(viewModel) applyLoadedPrefill(viewModel) - viewModel.removeMemorialPhoto() - viewModel.removeMemorialVideo() + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialPhoto) + viewModel.onIntent(AfternoteEditorIntent.RemoveMemorialVideo) viewModel.saveCurrentMemorialForm() advanceUntilIdle() assertNotNull(viewModel.uiState.value.error) assertNull(viewModel.uiState.value.savedId) - assertDeletedMediaAndSongs(viewModel.currentForm()) + assertDeletedMediaAndSongs(viewModel.uiState.value.form) viewModel.saveCurrentMemorialForm() advanceUntilIdle() @@ -149,17 +151,19 @@ class AfternoteEditorServerMediaDeleteSaveTest { private fun TestScope.applyLoadedPrefill(viewModel: AfternoteEditorViewModel) { advanceUntilIdle() val prefill = requireNotNull(viewModel.uiState.value.pendingPrefill) - viewModel.applyPrefill(prefill) - viewModel.onPrefillConsumed() + viewModel.onIntent(AfternoteEditorIntent.ApplyPrefill(prefill)) + viewModel.onIntent(AfternoteEditorIntent.ConsumePrefill) advanceUntilIdle() } private fun AfternoteEditorViewModel.saveCurrentMemorialForm() { - val form = currentForm() - saveAfternote( - payload = validMemorialPayload(), - selectedReceiverIds = emptyList(), - memorialMedia = form.fullMemorialMediaForSave(), + val form = uiState.value.form + onIntent( + AfternoteEditorIntent.Save( + payload = validMemorialPayload(), + selectedReceiverIds = emptyList(), + memorialMedia = form.fullMemorialMediaForSave(), + ), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index c9aa9d5fa2..5558f75dcb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -59,9 +59,9 @@ class AfternoteEditorThumbnailRetryTest { runTest(dispatcher) { val viewModel = viewModel(uploads = ThumbnailUploads(failures = 1)) observeUiState(viewModel) - viewModel.setMemorialVideo(LOCAL_VIDEO) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(LOCAL_VIDEO)) - viewModel.uploadMemorialThumbnail(JPEG_BYTES) + viewModel.onIntent(AfternoteEditorIntent.UploadMemorialThumbnail(JPEG_BYTES)) advanceUntilIdle() assertEquals( @@ -76,17 +76,21 @@ class AfternoteEditorThumbnailRetryTest { val uploads = ThumbnailUploads(failures = 1) val viewModel = viewModel(uploads) observeUiState(viewModel) - viewModel.setMemorialVideo(LOCAL_VIDEO) - viewModel.uploadMemorialThumbnail(JPEG_BYTES) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(LOCAL_VIDEO)) + viewModel.onIntent(AfternoteEditorIntent.UploadMemorialThumbnail(JPEG_BYTES)) advanceUntilIdle() - viewModel.retryMemorialThumbnail() + viewModel.onIntent(AfternoteEditorIntent.RetryMemorialThumbnail) advanceUntilIdle() assertEquals("두 번째 시도까지 두 번 올린다", 2, uploads.attempts) assertEquals(UPLOADED_URL, viewModel.uiState.value.pendingThumbnailUrl) // 고른 영상은 그대로다 — 되돌리려고 영상을 다시 고르게 하지 않는다. - assertEquals(LOCAL_VIDEO, viewModel.currentForm().displayedMemorialVideo?.url) + assertEquals( + LOCAL_VIDEO, + viewModel.uiState.value.form.displayedMemorialVideo + ?.url, + ) } @Test @@ -94,9 +98,9 @@ class AfternoteEditorThumbnailRetryTest { runTest(dispatcher) { val viewModel = viewModel() observeUiState(viewModel) - viewModel.setMemorialVideo(LOCAL_VIDEO) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(LOCAL_VIDEO)) - viewModel.onMemorialThumbnailExtractionFailed(IllegalStateException("no frame")) + viewModel.onIntent(AfternoteEditorIntent.MemorialThumbnailExtractionFailed(IllegalStateException("no frame"))) advanceUntilIdle() assertEquals( @@ -111,11 +115,11 @@ class AfternoteEditorThumbnailRetryTest { val uploads = ThumbnailUploads() val viewModel = viewModel(uploads) observeUiState(viewModel) - viewModel.setMemorialVideo(LOCAL_VIDEO) - viewModel.onMemorialThumbnailExtractionFailed(IllegalStateException("no frame")) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(LOCAL_VIDEO)) + viewModel.onIntent(AfternoteEditorIntent.MemorialThumbnailExtractionFailed(IllegalStateException("no frame"))) val before = viewModel.uiState.value.memorialThumbnailRetryToken - viewModel.retryMemorialThumbnail() + viewModel.onIntent(AfternoteEditorIntent.RetryMemorialThumbnail) advanceUntilIdle() // 토큰이 바뀌면 화면이 같은 영상에서 프레임 추출을 다시 발화한다. @@ -129,12 +133,12 @@ class AfternoteEditorThumbnailRetryTest { val uploads = ThumbnailUploads(failures = 1) val viewModel = viewModel(uploads) observeUiState(viewModel) - viewModel.setMemorialVideo(LOCAL_VIDEO) - viewModel.uploadMemorialThumbnail(JPEG_BYTES) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(LOCAL_VIDEO)) + viewModel.onIntent(AfternoteEditorIntent.UploadMemorialThumbnail(JPEG_BYTES)) advanceUntilIdle() - viewModel.setMemorialVideo("content://videos/another") - viewModel.retryMemorialThumbnail() + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo("content://videos/another")) + viewModel.onIntent(AfternoteEditorIntent.RetryMemorialThumbnail) advanceUntilIdle() // 남은 바이트로 다시 올리면 다른 영상의 그림이 붙는다. diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index 7057dd9a4a..059a12a3f3 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -60,7 +60,7 @@ class AfternoteEditorValidationEventTest { val first = requireNotNull(viewModel.uiState.value.errorEvent) // 소비(null)와 다음 동일 오류를 UI 수집기가 각각 관찰하지 못하고 합쳐도, 새 occurrence로 구분돼야 한다. - viewModel.onErrorConsumed(first) + viewModel.onIntent(AfternoteEditorIntent.ConsumeError(first)) viewModel.saveInvalidSocialAfternote() runCurrent() val second = requireNotNull(viewModel.uiState.value.errorEvent) @@ -78,27 +78,29 @@ class AfternoteEditorValidationEventTest { assertEquals(second.error, third.error) assertNotEquals("소비 전 같은 오류도 저장 시도마다 별도 UI 이벤트여야 한다", second, third) - viewModel.onErrorConsumed(second) + viewModel.onIntent(AfternoteEditorIntent.ConsumeError(second)) runCurrent() assertEquals("이전 Snackbar의 종료가 최신 이벤트를 지우면 안 된다", third, viewModel.uiState.value.errorEvent) - viewModel.onErrorConsumed(third) + viewModel.onIntent(AfternoteEditorIntent.ConsumeError(third)) runCurrent() assertNull(viewModel.uiState.value.errorEvent) } private fun AfternoteEditorViewModel.saveInvalidSocialAfternote() { - saveAfternote( - payload = - RegisterAfternotePayload( - serviceName = "", - date = "2026.08.27", - accountId = "account", - password = "password", - processingMethods = listOf("계정 삭제"), - ), - selectedReceiverIds = listOf(1L), - memorialMedia = SaveAfternoteMemorialMedia(), + onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = "", + date = "2026.08.27", + accountId = "account", + password = "password", + processingMethods = listOf("계정 삭제"), + ), + selectedReceiverIds = listOf(1L), + memorialMedia = SaveAfternoteMemorialMedia(), + ), ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt index 20af2f2313..ec531e9ca4 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorContentSignatureCompletenessTest.kt @@ -1,6 +1,14 @@ package com.afternote.feature.afternote.presentation.editor +import androidx.activity.ComponentActivity import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.editor.receiver.AfternoteEditorReceiver import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorState @@ -10,23 +18,73 @@ import com.afternote.feature.afternote.presentation.editor.state.EditableMemoria import com.afternote.feature.afternote.presentation.editor.state.EditorFormState import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config -/** - * 이탈 가드 상태 지문([editorContentSignature])의 완전성 계약 테스트. - * - * [AfternoteEditorState]의 [TextFieldState]를 리플렉션으로 전수 열거한 뒤 하나씩 실제로 - * 입력해 보고 지문이 반응하는지 검사한다. 상태 홀더에 입력 상태가 새로 생기면 이 테스트가 자동으로 - * 그 필드를 집어 들므로, 지문에 반영하거나 [dialogTransientStates] 에 사유와 함께 올리는 결정이 - * 강제된다. 폼 쪽은 공용 필드가 통째 직렬화라 필드 추가가 자동 포함이고, 카테고리 축은 - * [AfternoteTypeForm.pristineFor] 의 exhaustive `when` 이 컴파일 단계에서 누락을 잡는다 — 양쪽에 카나리만 둔다. - */ +/** 내부 지문 대신 실제 화면의 뒤로가기·이탈 경고 계약으로 입력 누락과 오탐을 검증한다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class EditorContentSignatureCompletenessTest { - /** 서비스 시트를 닫거나 선택하면 비우는 검색 query라 이탈 지문에서 제외한다. */ - private val transientUiStates = - setOf("serviceSearchQueryState") + @get:Rule val composeRule = createAndroidComposeRule() + private val activeCase = mutableStateOf(null) + private val transientUiStates = setOf("serviceSearchQueryState") + + @Before + fun setUp() { + composeRule.setContent { + activeCase.value?.let { case -> + key(case) { + AfternoteTheme { + AfternoteEditorScreen( + form = case.form.value, + state = case.state, + onBackClick = { case.backCalls++ }, + onRegisterClick = {}, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, + content = {}, + ) + } + } + } + } + } + + private class ExitCase( + initialForm: EditorFormState, + val state: AfternoteEditorState, + ) { + val form = mutableStateOf(initialForm) + var backCalls = 0 + } + + private fun assertExitGuard( + before: EditorFormState = EditorFormState(), + after: EditorFormState = before, + changed: Boolean = true, + prepare: (AfternoteEditorState) -> Unit = {}, + edit: (AfternoteEditorState) -> Unit = {}, + ) { + val state = newState().also(prepare) + val case = ExitCase(before, state) + composeRule.runOnIdle { activeCase.value = case } + composeRule.waitForIdle() + composeRule.runOnIdle { + case.form.value = after + edit(state) + } + composeRule.onNodeWithContentDescription("뒤로가기").performClick() + val warning = composeRule.onNodeWithText("작성 중인 내용이 사라집니다.", substring = true) + if (changed) warning.assertExists() else warning.assertDoesNotExist() + assertEquals(if (changed) 0 else 1, case.backCalls) + } private fun newState(): AfternoteEditorState = AfternoteEditorState( @@ -50,193 +108,84 @@ class EditorContentSignatureCompletenessTest { editProcessingMethod = { _, _ -> }, ) - private fun textFieldStateGetters() = - AfternoteEditorState::class.java.methods - .filter { it.parameterCount == 0 && it.returnType == TextFieldState::class.java } - @Test - fun `에디터 상태의 모든 TextFieldState 는 지문에 반영되거나 제외 사유가 명시돼야 한다`() { - val getters = textFieldStateGetters() - assertTrue("TextFieldState 게터 열거 실패 — 리플렉션 전제 붕괴", getters.size >= 3) - + fun `모든 TextFieldState 편집은 이탈 경고에 반영되거나 제외 사유가 있어야 한다`() { + val getters = + AfternoteEditorState::class.java.methods + .filter { it.parameterCount == 0 && it.returnType == TextFieldState::class.java } + assertTrue(getters.size >= 3) getters.forEach { getter -> val name = getter.name.removePrefix("get").replaceFirstChar { it.lowercase() } - val state = newState() - val form = EditorFormState() - val before = editorContentSignature(form, state) - (getter.invoke(state) as TextFieldState).edit { replace(0, length, "완전성테스트입력") } - val after = editorContentSignature(form, state) - if (name in transientUiStates) { - assertEquals("휘발 UI 입력 '$name' 은 지문에서 제외돼야 한다", before, after) - } else { - assertNotEquals( - "'$name' 입력이 지문에 반영되지 않는다 — editorContentSignature 에 추가하거나 " + - "transientUiStates 에 사유와 함께 올릴 것", - before, - after, - ) + assertExitGuard(changed = name !in transientUiStates) { state -> + (getter.invoke(state) as TextFieldState).edit { replace(0, length, "완전성테스트입력") } } } } @Test - fun `메시지 블록 타이핑이 지문에 반영된다`() { - val state = newState() - val form = EditorFormState() - state.addEditorMessage() - val before = editorContentSignature(form, state) - state.editorMessages - .first() - .titleState - .edit { replace(0, length, "제목") } - assertNotEquals(before, editorContentSignature(form, state)) - } - - @Test - fun `공용 폼 필드 변경이 지문에 반영된다 - 통째 직렬화 카나리`() { - val state = newState() - val before = editorContentSignature(EditorFormState(), state) - val after = - editorContentSignature( - EditorFormState( - afternoteEditReceivers = listOf(AfternoteEditorReceiver(id = 1L, name = "김수신", label = "딸")), - ), - state, - ) - assertNotEquals(before, after) + fun `메시지 블록 입력 후 이탈을 경고한다`() { + assertExitGuard(prepare = { it.addEditorMessage() }) { state -> + state.editorMessages + .first() + .titleState + .edit { replace(0, length, "제목") } + } } @Test - fun `카테고리 전용 입력 변경이 지문에 반영된다 - 같은 카테고리끼리 비교`() { - val state = newState() - val before = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state) - val after = - editorContentSignature( - EditorFormState( - typeForm = AfternoteTypeForm.Memorial(photo = EditableMemorialPhoto.empty().withSelection("content://photo")), - ), - state, - ) - assertNotEquals(before, after) + fun `수신자와 카테고리 전용 입력 변경 후 이탈을 경고한다`() { + assertExitGuard(after = EditorFormState(afternoteEditReceivers = listOf(AfternoteEditorReceiver(1L, "수신자", "딸")))) + assertExitGuard(before = memorial(), after = memorial(photo = "content://photo")) + assertExitGuard(after = EditorFormState(typeForm = AfternoteTypeForm.Social(selectedService = "인스타그램"))) } @Test - fun `서비스 선택이 지문에 반영된다 - 미선택(null)과 구분`() { - val state = newState() - val before = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Social()), state) - val after = - editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.Social(selectedService = "인스타그램")), - state, - ) - assertNotEquals(before, after) + fun `입력 없는 카테고리 구경은 경고하지 않는다`() { + AfternoteType.entries.forEach { type -> + assertExitGuard(after = EditorFormState(typeForm = AfternoteTypeForm.pristineFor(type)), changed = false) + } } @Test - fun `어느 카테고리를 골라도 입력이 없으면 지문이 같다`() { - val state = newState() - val signatures = - AfternoteType.entries.map { type -> - editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.pristineFor(type)), - state, - ) - } - assertEquals( - "카테고리 구경은 변경이 아니다 — 입력이 빈 카테고리는 전부 같은 지문이어야 한다", - 1, - signatures.distinct().size, + fun `카테고리 전환으로 입력을 버리면 경고한다`() { + assertExitGuard( + before = EditorFormState(typeForm = AfternoteTypeForm.Social(selectedService = "인스타그램")), + after = EditorFormState(typeForm = AfternoteTypeForm.Gallery()), ) } @Test - fun `카테고리 전환으로 입력값이 버려지면 지문이 달라진다`() { - val state = newState() - val filled = - editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.Social(selectedService = "인스타그램")), - state, - ) - val switched = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Gallery()), state) - assertNotEquals(filled, switched) + fun `선택 영상은 경고하고 자동 파생 썸네일만 바뀌면 경고하지 않는다`() { + val video = EditableMemorialVideo.empty().withSelection("content://video") + assertExitGuard(before = memorial(), after = memorial(video)) + assertExitGuard( + before = memorial(video), + after = memorial(video.withSelectionThumbnail("https://cdn.test/thumb.jpg")), + changed = false, + ) } @Test - fun `자동 파생 썸네일은 지문에서 제외되고 영상 자체는 반영된다`() { - val state = newState() - val picked = MemorialVideoAttachment(url = "content://video") - val pristine = editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state) - val withVideo = - editorContentSignature( - EditorFormState( - typeForm = - AfternoteTypeForm.Memorial( - video = EditableMemorialVideo.empty().withSelection(picked.url), - ), - ), - state, - ) - - assertNotEquals("고른 영상 자체는 사용자 입력이다", pristine, withVideo) - assertEquals( - "썸네일은 영상에서 파생된 값이라 사용자 입력이 아니다 — 복원 뒤 재추출로 URL 이 바뀌어도 지문은 그대로여야 한다", - withVideo, - editorContentSignature( - EditorFormState( - typeForm = - AfternoteTypeForm.Memorial( - video = - EditableMemorialVideo - .empty() - .withSelection(picked.url) - .withSelectionThumbnail("https://cdn.test/thumb.jpg"), - ), - ), - state, - ), + fun `서버 영상 삭제는 경고하고 서버 썸네일만 바뀌면 경고하지 않는다`() { + val attachment = MemorialVideoAttachment("https://cdn.test/video.mp4", "https://cdn.test/thumb.jpg") + val video = EditableMemorialVideo.fromPersisted(attachment) + assertExitGuard(before = memorial(video), after = memorial()) + assertExitGuard( + before = memorial(video), + after = memorial(EditableMemorialVideo.fromPersisted(attachment.copy(thumbnailUrl = "https://cdn.test/other.jpg"))), + changed = false, ) } - /** - * #1561 은 「prefill 이 채운 서버 원본은 지문에서 제외된다」 는 카나리로 «사용자 입력만 센다» 를 잠갔다. - * #1597 이 서버 원본 삭제를 열면서 그 존재 자체가 사용자가 바꿀 수 있는 사실이 됐으므로 지문은 수정 - * 진입 기준선과 비교해 서버 원본을 포함한다. 저장 성공 후 서버 값만 새로고침하는 경로가 생기면 그때 - * 기준선을 다시 잡아야 한다 — 제외로 되돌리면 서버 삭제가 이탈 경고 없이 사라진다. - */ - @Test - fun `서버 영상 삭제는 지문에 반영되고 서버 썸네일만 달라지면 제외된다`() { - val state = newState() - val serverVideo = - MemorialVideoAttachment( - url = "https://cdn.test/farewell.mp4", - thumbnailUrl = "https://cdn.test/farewell-thumb.jpg", - ) - val withServerVideo = - editorContentSignature( - EditorFormState(typeForm = AfternoteTypeForm.Memorial(video = EditableMemorialVideo.fromPersisted(serverVideo))), - state, - ) - - assertNotEquals( - "서버 원본 삭제는 저장하지 않고 이탈할 때 경고해야 한다", - withServerVideo, - editorContentSignature(EditorFormState(typeForm = AfternoteTypeForm.Memorial()), state), - ) - assertEquals( - "서버 썸네일은 영상에서 파생된 값이라 지문에서 제외한다", - withServerVideo, - editorContentSignature( - EditorFormState( - typeForm = - AfternoteTypeForm.Memorial( - video = - EditableMemorialVideo.fromPersisted( - serverVideo.copy(thumbnailUrl = "https://cdn.test/another-thumb.jpg"), - ), - ), - ), - state, + private fun memorial( + video: EditableMemorialVideo = EditableMemorialVideo.empty(), + photo: String? = null, + ) = EditorFormState( + typeForm = + AfternoteTypeForm.Memorial( + video = video, + photo = + photo?.let { EditableMemorialPhoto.empty().withSelection(it) } ?: EditableMemorialPhoto.empty(), ), - ) - } + ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt index 719daf2fd1..5f16011a75 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheetTest.kt @@ -41,8 +41,9 @@ class EditorServiceSelectionSheetTest { val queryState = TextFieldState() composeRule.setContent { AfternoteTheme { - EditorServiceSelectionSheetContent( - title = "소셜 네트워크 서비스 선택", + EditorServiceSelectionSheet( + visible = true, + onDismissRequest = {}, type = AfternoteType.SOCIAL_NETWORK, services = services, searchQueryState = queryState, @@ -97,8 +98,9 @@ class EditorServiceSelectionSheetTest { val queryState = TextFieldState() composeRule.setContent { AfternoteTheme { - EditorServiceSelectionSheetContent( - title = "비즈니스 서비스 선택", + EditorServiceSelectionSheet( + visible = true, + onDismissRequest = {}, type = AfternoteType.BUSINESS, services = AfternoteServiceCatalog.businessServices, searchQueryState = queryState, @@ -122,8 +124,9 @@ class EditorServiceSelectionSheetTest { val queryState = TextFieldState() composeRule.setContent { AfternoteTheme { - EditorServiceSelectionSheetContent( - title = "소셜 네트워크 서비스 선택", + EditorServiceSelectionSheet( + visible = true, + onDismissRequest = {}, type = AfternoteType.SOCIAL_NETWORK, services = listOf("인스타그램", "페이스북"), searchQueryState = queryState, @@ -132,7 +135,11 @@ class EditorServiceSelectionSheetTest { } } - composeRule.assertAccessibleClickTargets() + // 시트째 띄우면 Material3 스크림(이름만 있고 Role 없음)이 스윕에 들어온다. 우리 화면 + // 노드가 아니므로 그것만 빼고 검색 입력·빈 결과 안내·행 전체의 48dp·이름·role·중첩 + // 클릭 계약(#1167·#1179·#1669)은 그대로 본다. + val closeSheet = composeRule.activity.getString(androidx.compose.ui.R.string.close_sheet) + composeRule.assertAccessibleClickTargets(exclude = { it.name == closeSheet }) val rows = composeRule .scanEnabledClickTargets() diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 9535e5d60d..f0c26766b4 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -368,7 +368,6 @@ class MviContractKonsistTest { setOf( "com.afternote.feature.afternote.presentation.AfternoteHostViewModel", "com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel", - "com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel", "com.afternote.feature.afternote.presentation.editor.memorial.AddSongViewModel", "com.afternote.feature.afternote.presentation.editor.receiver.SelectReceiverViewModel", "com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel", diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index 93a157b588..d4af5c241f 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -303,10 +303,6 @@ class ProductionVisibilityKonsistTest { val ISSUE_1673_AFTERNOTE = setOf( "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt::function DeleteInProgressOverlay [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContent.kt::function EditorContent [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt::function editorContentSignature [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt::function toAfternoteEditorError [internal]", - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/selection/EditorServiceSelectionSheet.kt::function EditorServiceSelectionSheetContent [internal]", ) val ISSUE_1674_MINDRECORD = @@ -393,9 +389,6 @@ class ProductionVisibilityKonsistTest { "core/ui/src/main/kotlin/com/afternote/core/ui/button/" + "AfternoteRadioGroup.kt::function AfternoteRadioGroup [implicit-public]" to "core:ui README에 문서화된 공용 컴포넌트", - "core/ui/src/main/kotlin/com/afternote/core/ui/mvi/" + - "MviViewModel.kt::class MviViewModel [implicit-public]" to - "MVI 베이스 계약(#1800) — 화면 전환 이슈가 상속하기 시작하면 프로덕션 참조가 생긴다", "core/ui/src/main/kotlin/com/afternote/core/ui/mvi/" + "ObserveSignal.kt::function ObserveSignal [implicit-public]" to "MVI 일회성 신호 소비 관용구(#1800) — 화면 전환 이슈가 소비처를 만든다", From 8021dde2db1b51edb34ad380463707434d9f8eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 12 Sep 2026 06:43:02 +0900 Subject: [PATCH 007/116] =?UTF-8?q?chore:=20=EC=98=9B=20guard=20check-run?= =?UTF-8?q?=20=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EB=A0=A4=20HEAD=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4=20(#1983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHA 에 붙은 재게시 guard check-run 은 지울 수 없어 새 HEAD 로만 사라진다. From ee53fe85c152d447ff2eff0c1e67f354d0a1ae66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 13 Sep 2026 12:38:34 +0900 Subject: [PATCH 008/116] =?UTF-8?q?refactor(afternote):=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=99=B8=20ViewModel=208=EA=B0=9C?= =?UTF-8?q?=EB=A5=BC=20MVI=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#1968)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../AfternoteAuthorExtendedAndroidTest.kt | 8 +- .../AfternoteHomeRefreshBannerAndroidTest.kt | 8 +- .../AppAndReceiverCompletionAndroidTest.kt | 22 +-- .../ReceiverAdvancedAndroidTest.kt | 13 +- .../afternote/presentation/build.gradle.kts | 10 ++ .../presentation/AfternoteHostContract.kt | 21 +++ .../presentation/AfternoteHostViewModel.kt | 64 ++++--- .../detail/AfternoteDetailContent.kt | 161 +++++++++++++++++ .../detail/AfternoteDetailContract.kt | 40 +++++ .../detail/AfternoteDetailRoute.kt | 169 ++---------------- .../detail/AfternoteDetailUiState.kt | 7 +- .../detail/AfternoteDetailViewModel.kt | 156 ++++++++-------- .../editor/memorial/AddSongContract.kt | 29 +++ .../editor/memorial/AddSongEntry.kt | 23 ++- .../editor/memorial/AddSongScreen.kt | 36 +--- .../editor/memorial/AddSongUiState.kt | 5 +- .../editor/memorial/AddSongViewModel.kt | 89 +++++---- .../editor/receiver/SelectReceiverContract.kt | 34 ++++ .../editor/receiver/SelectReceiverRoute.kt | 6 +- .../receiver/SelectReceiverViewModel.kt | 119 +++++++----- .../home/AfternoteHomeContract.kt | 28 +++ .../presentation/home/AfternoteHomeEntry.kt | 13 +- .../home/AfternoteHomeViewModel.kt | 40 +++-- .../navigation/AfternoteNavHost.kt | 20 ++- .../ReceiverAfternoteHomeContract.kt | 22 +++ .../ReceiverAfternoteHomeEntry.kt | 20 ++- .../ReceiverAfternoteHomeViewModel.kt | 36 ++-- .../detail/ReceivedAfternoteDetailContract.kt | 23 +++ .../detail/ReceivedAfternoteDetailRoute.kt | 18 +- .../detail/ReceivedAfternoteDetailUiState.kt | 3 +- .../ReceivedAfternoteDetailViewModel.kt | 69 ++++--- .../navigation/ReceivedAfternoteNavHost.kt | 40 +---- .../ReceiverMemorialPlaylistContract.kt | 22 +++ .../playlist/ReceiverMemorialPlaylistRoute.kt | 46 +++++ .../ReceiverMemorialPlaylistUiState.kt | 3 +- .../ReceiverMemorialPlaylistViewModel.kt | 78 +++++--- .../DeleteInProgressOverlayScreenshotTest.kt | 31 ---- .../memorial/AddSongScreenScreenshotTest.kt | 6 +- ...InProgressOverlayScreenshot_748aa731_0.png | Bin 19392 -> 0 bytes .../AfternoteAuthorExtendedTest.kt | 7 +- .../AfternoteHostViewModelTest.kt | 61 +++++++ .../detail/AfternoteDetailContentTest.kt | 67 +++++++ .../detail/AfternoteDetailViewModelTest.kt | 34 ++-- .../memorial/AddSongSearchProgressTest.kt | 3 +- .../editor/memorial/AddSongViewModelTest.kt | 64 +++++++ .../receiver/SelectReceiverViewModelTest.kt | 46 ++--- .../AfternoteHomeListFailureReportingTest.kt | 14 +- .../ReceivedAfternoteDetailViewModelTest.kt | 18 +- .../ReceiverMemorialPlaylistViewModelTest.kt | 16 +- .../testing/AfternoteScreenFixtures.kt | 52 ++++++ .../konsist/MviContractKonsistTest.kt | 14 -- .../ProductionVisibilityKonsistTest.kt | 13 -- .../RefreshOnReturnWiringKonsistTest.kt | 25 ++- 54 files changed, 1284 insertions(+), 689 deletions(-) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt delete mode 100644 feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt delete mode 100644 feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt create mode 100644 feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 822a4301dc..4f935a633a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -303,6 +303,7 @@ dependencies { androidTestImplementation(testFixtures(projects.core.domain)) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(testFixtures(projects.feature.afternote.domain)) + androidTestImplementation(testFixtures(projects.feature.afternote.presentation)) androidTestImplementation(projects.feature.mindrecord.domain) androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 3d20af2511..eba20e2b69 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -25,8 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -66,17 +65,16 @@ class AfternoteAuthorExtendedAndroidTest { listFlows[null] = Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty()) - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) val detailRoutes = mutableListOf() val addRoutes = mutableListOf() composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = detailRoutes::add, navigateToAdd = addRoutes::add, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt index 711c00e8cc..d1499270ba 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -20,8 +20,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -77,15 +76,14 @@ class AfternoteHomeRefreshBannerAndroidTest { }.flow } } - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = {}, navigateToAdd = {}, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index d915df65e5..671e896551 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -43,9 +43,7 @@ import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.home.presentation.receiver.ReceiverHomeEvent import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository @@ -521,20 +519,19 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 202L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 202L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } @@ -571,9 +568,9 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 303L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 303L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -581,11 +578,10 @@ class ReceiverRuntimeCompletionAndroidTest { composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = playlistRoutes::add, - viewModel = viewModel, ) } } 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 8b52e0098f..aed9ab51fc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -25,9 +25,7 @@ import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -177,20 +175,19 @@ class ReceiverAdvancedAndroidTest { } detailResults.addLast(Result.failure(IllegalStateException("offline"))) detailResults.addLast(Result.success(receivedSocialDetail())) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 91L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 91L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index 51fc3ec066..3fbec2fea9 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -9,6 +9,7 @@ plugins { android { namespace = "com.afternote.feature.afternote.presentation" resourcePrefix = "afternote_" + testFixtures.enable = true experimentalProperties["android.experimental.enableScreenshotTest"] = true buildFeatures { buildConfig = true @@ -42,6 +43,15 @@ dependencies { implementation(libs.androidx.paging.compose) implementation(libs.androidx.core.ktx) + // 앱 기기 테스트는 fixture를 통해 실제 화면을 열며 production ViewModel을 공개하지 않는다. + testFixturesImplementation(platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) + testFixturesImplementation(projects.feature.afternote.domain) + testFixturesImplementation(projects.feature.receiver.domain) + testFixturesImplementation(projects.core.common) + testFixturesImplementation(projects.core.ui) + testFixturesImplementation(libs.androidx.navigation3.runtime) + testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) testImplementation(testFixtures(projects.feature.afternote.domain)) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt new file mode 100644 index 0000000000..89e0148dac --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt @@ -0,0 +1,21 @@ +package com.afternote.feature.afternote.presentation + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class AfternoteHostUiState( + val isPasskeyRegistered: Boolean? = null, +) : UiState + +internal sealed interface AfternoteHostIntent : MviIntent { + data object ObserveProfile : AfternoteHostIntent + + data object StopObservingProfile : AfternoteHostIntent +} + +internal sealed interface AfternoteHostReducerEvent : ReducerEvent { + data class PasskeyRegistrationChanged( + val registered: Boolean, + ) : AfternoteHostReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt index 3cb51d3971..5ea34444d1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt @@ -1,33 +1,53 @@ package com.afternote.feature.afternote.presentation -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import javax.inject.Inject -/** - * [com.afternote.core.ui.Route.Afternote] 서브그래프 스코프의 상태 정본. - * - * 에디터 flow 상태는 flow-scoped - * [com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel]이 담당한다. - * 본 ViewModel은 Afternote 그래프 전체에서 공유하는 사용자 상태만 보유한다. - */ +/** Afternote 그래프의 프로필 상태. 지문 인증 화면을 떠난 뒤에도 기존 WhileSubscribed와 같은 5초 유예를 둔다. */ @HiltViewModel -class AfternoteHostViewModel +internal class AfternoteHostViewModel @Inject constructor( - userProfileRepository: UserProfileCacheRepository, - ) : ViewModel() { - val isPasskeyRegistered: StateFlow = - userProfileRepository - .isPasskeyRegisteredFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = null, - ) + private val userProfileRepository: UserProfileCacheRepository, + ) : MviViewModel(AfternoteHostUiState()) { + private var profileJob: Job? = null + private var stopJob: Job? = null + + override fun onIntent(intent: AfternoteHostIntent) { + when (intent) { + AfternoteHostIntent.ObserveProfile -> { + stopJob?.cancel() + if (profileJob?.isActive == true) return + profileJob = + viewModelScope.launch { + userProfileRepository.isPasskeyRegisteredFlow().collect { + dispatch(AfternoteHostReducerEvent.PasskeyRegistrationChanged(it)) + } + } + } + + AfternoteHostIntent.StopObservingProfile -> { + stopJob?.cancel() + stopJob = + viewModelScope.launch { + delay(5_000) + profileJob?.cancel() + } + } + } + } + + override fun reduce( + state: AfternoteHostUiState, + event: AfternoteHostReducerEvent, + ): AfternoteHostUiState = + when (event) { + is AfternoteHostReducerEvent.PasskeyRegistrationChanged -> state.copy(isPasskeyRegistered = event.registered) + } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt new file mode 100644 index 0000000000..ec48b68095 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt @@ -0,0 +1,161 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.detail.account.AccountDetailScreen +import com.afternote.feature.afternote.presentation.shared.detail.DesignPendingDetailContent +import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent + +/** 상세 상태의 모든 렌더 분기. 상태 수집과 일회성 결과 소비는 Navigation이 담당한다. */ +@Composable +internal fun AfternoteDetailContent( + uiState: AfternoteDetailUiState, + onIntent: (AfternoteDetailIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNavigateBack: () -> Unit, + onNavigateToEditor: (Long, AfternoteType) -> Unit, + onVideoClick: (String) -> Unit, +) { + when (val state = uiState) { + AfternoteDetailUiState.Loading -> { + LoadingBody() + } + + is AfternoteDetailUiState.Error -> { + DetailLoadErrorContent( + messageRes = state.messageRes, + onBackClick = onNavigateBack, + onRetryClick = { onIntent(AfternoteDetailIntent.Retry) }, + ) + } + + is AfternoteDetailUiState.Success -> { + AfternoteDetailSuccessContent( + state = state, + snackbarHostState = snackbarHostState, + onBackClick = onNavigateBack, + onNavigateToEditor = onNavigateToEditor, + onDeleteConfirm = { onIntent(AfternoteDetailIntent.Delete) }, + onVideoClick = onVideoClick, + ) + } + } +} + +@Composable +private fun AfternoteDetailSuccessContent( + state: AfternoteDetailUiState.Success, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit, + onDeleteConfirm: () -> Unit, + onVideoClick: (String) -> Unit, +) { + Box { + when (val model = state.contentUiModel) { + is DetailContentUiModel.SocialNetwork -> { + AccountDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Business -> { + AccountDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Gallery -> { + GalleryDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Memorial -> { + MemorialDetailScreen( + content = model.content, + userName = state.authorDisplayName, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + onVideoClick = onVideoClick, + ) + } + + DetailContentUiModel.Unimplemented -> { + DesignPendingDetailContent(onBackClick = onBackClick) + } + } + + if (state.isDeleting) { + DeleteInProgressOverlay() + } + } +} + +/** + * 삭제 진행([AfternoteDetailUiState.Success.isDeleting]) 동안 상세 화면 위에 겹쳐 그리는 오버레이. + * + * 반투명 스크림 + 중앙 진행 인디케이터. 중복 delete 호출은 ViewModel 이 이미 가드하므로 + * 여기서는 시각 표시와 입력 차단만 담당한다. + * + * 포인터 차단만으로는 부족하다 — 스크림에 semantics 가 없으면 접근성 트리에서 잘리지 않아 + * 아래 상세 화면의 뒤로·수정·삭제 버튼이 스크린리더로는 그대로 탐색·활성화된다. + * 그래서 스크림 자체를 병합 노드로 만들어 모달성을 유지한다. + */ +@Composable +private fun DeleteInProgressOverlay() { + val deletingDescription = stringResource(R.string.afternote_detail_deleting) + Box( + modifier = + Modifier + .fillMaxSize() + .background(AfternoteDesign.colors.black.copy(alpha = 0.3f)) + .pointerInput(Unit) { detectTapGestures {} } + .semantics(mergeDescendants = true) { + contentDescription = deletingDescription + liveRegion = LiveRegionMode.Polite + }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt new file mode 100644 index 0000000000..deb0e200b1 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt @@ -0,0 +1,40 @@ +package com.afternote.feature.afternote.presentation.detail + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface AfternoteDetailIntent : MviIntent { + data object Retry : AfternoteDetailIntent + + data object RefreshOnReturn : AfternoteDetailIntent + + data object Delete : AfternoteDetailIntent + + data object ConsumeDeleteResult : AfternoteDetailIntent +} + +internal sealed interface AfternoteDetailReducerEvent : ReducerEvent { + data object Loading : AfternoteDetailReducerEvent + + data class ContentLoaded( + val id: Long, + val content: DetailContentUiModel, + val authorName: String, + ) : AfternoteDetailReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : AfternoteDetailReducerEvent + + data object DeleteStarted : AfternoteDetailReducerEvent + + data class DeleteFinished( + val result: AfternoteDetailDeleteResult, + ) : AfternoteDetailReducerEvent + + data object DeleteResultConsumed : AfternoteDetailReducerEvent + + data class AuthorNameChanged( + val name: String, + ) : AfternoteDetailReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt index 75540b9afd..30b971f198 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt @@ -1,80 +1,21 @@ package com.afternote.feature.afternote.presentation.detail -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.common.media.launchMemorialVideo -import com.afternote.core.ui.loading.LoadingBody -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.detail.account.AccountDetailScreen -import com.afternote.feature.afternote.presentation.shared.detail.DesignPendingDetailContent -import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent import kotlinx.coroutines.launch -/** - * 삭제 진행([AfternoteDetailUiState.Success.isDeleting]) 동안 상세 화면 위에 겹쳐 그리는 오버레이. - * - * 반투명 스크림 + 중앙 진행 인디케이터. 중복 delete 호출은 ViewModel 이 이미 가드하므로 - * 여기서는 시각 표시와 입력 차단만 담당한다. - * - * 포인터 차단만으로는 부족하다 — 스크림에 semantics 가 없으면 접근성 트리에서 잘리지 않아 - * 아래 상세 화면의 뒤로·수정·삭제 버튼이 스크린리더로는 그대로 탐색·활성화된다. - * 그래서 스크림 자체를 병합 노드로 만들어 모달성을 유지한다. - */ -@Composable -internal fun DeleteInProgressOverlay() { - val deletingDescription = stringResource(R.string.afternote_detail_deleting) - Box( - modifier = - Modifier - .fillMaxSize() - .background(AfternoteDesign.colors.black.copy(alpha = 0.3f)) - .pointerInput(Unit) { detectTapGestures {} } - .semantics(mergeDescendants = true) { - contentDescription = deletingDescription - liveRegion = LiveRegionMode.Polite - }, - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } -} - /** * 삭제 실패 메시지를 [snackbarHostState] Snackbar 로 표출하는 [ObserveDeleteResult] 용 `onDeleteFailed` 핸들러. * @@ -113,7 +54,7 @@ internal fun rememberDeleteFailedHandler(snackbarHostState: SnackbarHostState): * - [AfternoteDetailDeleteResult.Failed] → [onDeleteFailed] (에러 UI는 화면별 Snackbar/Dialog 책임. * 보통 [rememberDeleteFailedHandler] 를 전달한다. 무음 삼킴 방지를 위해 필수 파라미터.) * - * 처리 후 [onConsumed] 콜백으로 ViewModel 의 [AfternoteDetailViewModel.onDeleteResultConsumed] + * 처리 후 [onConsumed] 콜백으로 ViewModel 의 [AfternoteDetailIntent.ConsumeDeleteResult] * 호출 → state reset (재합성 시 중복 처리 방지). */ @Composable @@ -158,112 +99,24 @@ internal fun AfternoteDetailNavigation( // 로딩을 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME 스킵(진입은 init // 로드가 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다 — 결선부는 무조건 부른다. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) } ObserveDeleteResult( deleteResult = (uiState as? AfternoteDetailUiState.Success)?.deleteResult, - onConsumed = viewModel::onDeleteResultConsumed, + onConsumed = { viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) }, onDeleteSucceeded = onNavigateBack, onDeleteFailed = rememberDeleteFailedHandler(snackbarHostState), ) - when (val state = uiState) { - AfternoteDetailUiState.Loading -> { - LoadingBody() - } - - is AfternoteDetailUiState.Error -> { - DetailLoadErrorContent( - messageRes = state.messageRes, - onBackClick = onNavigateBack, - onRetryClick = viewModel::retry, - ) - } - - is AfternoteDetailUiState.Success -> { - AfternoteDetailSuccessContent( - state = state, - snackbarHostState = snackbarHostState, - onBackClick = onNavigateBack, - onNavigateToEditor = onNavigateToEditor, - onDeleteConfirm = viewModel::deleteAfternote, - onVideoClick = onVideoClick, - ) - } - } -} - -@Composable -private fun AfternoteDetailSuccessContent( - state: AfternoteDetailUiState.Success, - snackbarHostState: SnackbarHostState, - onBackClick: () -> Unit, - onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit, - onDeleteConfirm: () -> Unit, - onVideoClick: (String) -> Unit, -) { - Box { - when (val model = state.contentUiModel) { - is DetailContentUiModel.SocialNetwork -> { - AccountDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Business -> { - AccountDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Gallery -> { - GalleryDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Memorial -> { - MemorialDetailScreen( - content = model.content, - userName = state.authorDisplayName, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - onVideoClick = onVideoClick, - ) - } - - DetailContentUiModel.Unimplemented -> { - DesignPendingDetailContent(onBackClick = onBackClick) - } - } - - if (state.isDeleting) { - DeleteInProgressOverlay() - } - } + AfternoteDetailContent( + uiState = uiState, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onNavigateBack = onNavigateBack, + onNavigateToEditor = onNavigateToEditor, + onVideoClick = onVideoClick, + ) } @Composable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt index bfee97b9af..52ea6d5c3b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.detail import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState /** * 애프터노트 상세 화면 UI 상태. @@ -10,13 +11,13 @@ import androidx.annotation.StringRes * 작성자 표시명은 상세와 다른 출처(내 프로필)라 [DetailContentUiModel] 안에 섞지 않고 나란히 둔다 — * 섞으면 늦게 도착한 이름을 반영할 때 상세 원본으로 되돌아가 다시 매핑해야 한다. * 삭제 결과(성공/실패)는 [Success.deleteResult] 에 흡수 — UI 가 LaunchedEffect 로 소비 후 - * [AfternoteDetailViewModel.onDeleteResultConsumed] 로 reset. + * [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. * - [Error] 상세 데이터 조회 실패. 표시 문구는 [Error.messageRes] 하나로만 운반한다. * 예외 원문(`Throwable.message`)은 UI 에 싣지 않는다 — 서버 5xx 본문에 내부 SQL 이, * 역직렬화 실패 메시지에 응답 원문 발췌·DTO 클래스명이 섞여 오기 때문이다 * (`ApiException` 도 `message` 는 로그 전용이고 사용자 노출용으로 `serverMessage` 를 따로 둔다). */ -sealed interface AfternoteDetailUiState { +internal sealed interface AfternoteDetailUiState : UiState { data object Loading : AfternoteDetailUiState data class Success( @@ -28,7 +29,7 @@ sealed interface AfternoteDetailUiState { * 그때 화면은 이름 세그먼트를 생략해 렌더한다 (현재 소비처는 추억 노트 상세 제목 하나). */ val authorDisplayName: String = "", - /** 삭제 결과 신호 — UI 가 LaunchedEffect 로 소비 후 [AfternoteDetailViewModel.onDeleteResultConsumed] 로 reset. */ + /** 삭제 결과 신호 — UI 가 LaunchedEffect 로 소비 후 [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. */ val deleteResult: AfternoteDetailDeleteResult? = null, ) : AfternoteDetailUiState diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt index 3ca445e2b0..2e8eea7ef5 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt @@ -1,11 +1,11 @@ package com.afternote.feature.afternote.presentation.detail -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -17,10 +17,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -34,14 +30,14 @@ import kotlinx.coroutines.launch * * [AfternoteDetailUiState] 를 그대로 들고 [uiState] 로 노출한다 — Loading/Success/Error 3분기. * 삭제 결과(성공/실패)는 [AfternoteDetailUiState.Success.deleteResult] nullable 필드에 흡수한다 — - * UI 가 LaunchedEffect 로 소비한 뒤 [onDeleteResultConsumed] 로 reset. + * UI 가 LaunchedEffect 로 소비한 뒤 [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. * * 사용자 가시 메시지는 VM 에 하드코딩하지 않고 [androidx.annotation.StringRes] id 로만 노출한다. * 실패 시 예외 원문(`Throwable.message`)은 UI 로 넘기지 않는다 — 서버 5xx 본문·역직렬화 예외에 * 내부 SQL·응답 원문 발췌가 섞여 오므로 사용자에게 노출하면 안 된다. */ @HiltViewModel(assistedFactory = AfternoteDetailViewModel.Factory::class) -class AfternoteDetailViewModel +internal class AfternoteDetailViewModel @AssistedInject constructor( @Assisted private val route: AfternoteRoute.DetailRoute, @@ -49,12 +45,66 @@ class AfternoteDetailViewModel private val myProfileRepository: MyProfileRepository, private val userProfileRepository: UserProfileCacheRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel(AfternoteDetailUiState.Loading) { private val afternoteIdFromNav: Long = route.itemId - private val _uiState = MutableStateFlow(AfternoteDetailUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: AfternoteDetailIntent) { + when (intent) { + AfternoteDetailIntent.Retry -> retry() + AfternoteDetailIntent.RefreshOnReturn -> refreshOnReturn() + AfternoteDetailIntent.Delete -> deleteAfternote() + AfternoteDetailIntent.ConsumeDeleteResult -> dispatch(AfternoteDetailReducerEvent.DeleteResultConsumed) + } + } + + override fun reduce( + state: AfternoteDetailUiState, + event: AfternoteDetailReducerEvent, + ): AfternoteDetailUiState = + when (event) { + AfternoteDetailReducerEvent.Loading -> { + AfternoteDetailUiState.Loading + } + + is AfternoteDetailReducerEvent.ContentLoaded -> { + if (state is AfternoteDetailUiState.Success) { + state.copy(detailId = event.id, contentUiModel = event.content, authorDisplayName = event.authorName) + } else { + AfternoteDetailUiState.Success( + detailId = event.id, + contentUiModel = event.content, + authorDisplayName = event.authorName, + ) + } + } + + is AfternoteDetailReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is AfternoteDetailUiState.Success + ) { + state + } else { + AfternoteDetailUiState.Error(R.string.afternote_detail_load_error) + } + } + + AfternoteDetailReducerEvent.DeleteStarted -> { + state.withSuccess { it.copy(isDeleting = true) } + } + + is AfternoteDetailReducerEvent.DeleteFinished -> { + state.withSuccess { it.copy(isDeleting = false, deleteResult = event.result) } + } + + AfternoteDetailReducerEvent.DeleteResultConsumed -> { + state.withSuccess { it.copy(deleteResult = null) } + } + + is AfternoteDetailReducerEvent.AuthorNameChanged -> { + state.withSuccess { it.copy(authorDisplayName = event.name) } + } + } /** * 작성자 표시명 — 상세와 별도로 조회된다. 도착 전에는 빈 문자열이고, 도착하면 보고 있는 Success 와 @@ -115,7 +165,7 @@ class AfternoteDetailViewModel * [refreshOnReturn] 과 달리 사용자가 직접 누른 동작이라, 진행 중인 로드가 있어도 건너뛰지 않는다 — * 누른 결과가 조용히 사라지면 안 되므로 그 로드를 자르고 새로 시작한다. */ - fun retry() { + private fun retry() { loadDetail(afternoteIdFromNav, LoadTrigger.UserRequested) } @@ -130,7 +180,7 @@ class AfternoteDetailViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면(빠른 resume 연타 등) 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -170,7 +220,7 @@ class AfternoteDetailViewModel loadJob = viewModelScope.launch { if (showsLoading) { - _uiState.value = AfternoteDetailUiState.Loading + dispatch(AfternoteDetailReducerEvent.Loading) } val result = afternoteRepository.getDetail(id = afternoteId) // 잘린 로드가 값을 들고 돌아와 새 화면을 덮는 창을 닫는다. repository 는 취소를 다시 @@ -180,37 +230,12 @@ class AfternoteDetailViewModel .onSuccess { detail -> // 매핑은 update 밖에서 한 번만 한다 — update 의 람다는 경합 시 재실행된다. val contentUiModel = detail.toDetailContentUiModel() - _uiState.update { current -> - // 갱신은 «상세 부분만» 바꾼다. 진행 중인 삭제(isDeleting)와 미소비 삭제 - // 결과(deleteResult)는 이 로드와 무관한 다른 작업의 상태라, 새 Success 로 - // 덮으면 그 기본값이 삭제 진행 표시를 풀고 결과 안내를 지운다. - if (current is AfternoteDetailUiState.Success) { - current.copy( - detailId = detail.id, - contentUiModel = contentUiModel, - authorDisplayName = authorDisplayName, - ) - } else { - AfternoteDetailUiState.Success( - detailId = detail.id, - contentUiModel = contentUiModel, - authorDisplayName = authorDisplayName, - ) - } - } + dispatch(AfternoteDetailReducerEvent.ContentLoaded(detail.id, contentUiModel, authorDisplayName)) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 사용자에게 안 보이는 만큼 // 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.DETAIL_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is AfternoteDetailUiState.Success) { - current - } else { - AfternoteDetailUiState.Error( - messageRes = R.string.afternote_detail_load_error, - ) - } - } + dispatch(AfternoteDetailReducerEvent.LoadFailed(keepsStateOnFailure)) } } } @@ -218,7 +243,7 @@ class AfternoteDetailViewModel /** * 삭제는 상세를 보고 있을 때만 뜻이 있다 — 그 전제를 상태에서 직접 확인한다. * - * 상태 갱신은 [updateSuccess] 가 Success 밖에서 no-op 이라 이미 안전하지만, **서버 호출은 아니다.** + * 상태 갱신은 [reduce] 가 Success 밖에서 no-op 이라 이미 안전하지만, **서버 호출은 아니다.** * Success 가 아닐 때 이 함수가 불리면 노트는 지워지는데 UI 는 아무것도 모른다(진행 표시도, * 결과 안내도, 화면 pop 도 없다). 중복 호출 가드도 non-Success 에서는 늘 통과한다. * 지금은 결선상 Success 에서만 호출되지만, 그 불변식은 VM 밖(내비게이션 분기)에 있다. @@ -226,40 +251,27 @@ class AfternoteDetailViewModel * 지울 id 도 인자로 받지 않고 그 Success 에서 꺼낸다 — 화면에 보이는 것과 다른 항목을 지우는 * 경우를 시그니처에서 없앤다. 호출부가 넘기던 값도 같은 Success 의 `detailId` 였다. */ - fun deleteAfternote() { - val current = _uiState.value as? AfternoteDetailUiState.Success ?: return + private fun deleteAfternote() { + val current = currentState as? AfternoteDetailUiState.Success ?: return if (current.isDeleting) return val afternoteId = current.detailId viewModelScope.launch { - updateSuccess { it.copy(isDeleting = true) } + dispatch(AfternoteDetailReducerEvent.DeleteStarted) afternoteRepository .delete(id = afternoteId) .onSuccess { - updateSuccess { - it.copy( - isDeleting = false, - deleteResult = AfternoteDetailDeleteResult.Succeeded(afternoteId), - ) - } + dispatch(AfternoteDetailReducerEvent.DeleteFinished(AfternoteDetailDeleteResult.Succeeded(afternoteId))) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.DETAIL_DELETE, e) - updateSuccess { - it.copy( - isDeleting = false, - deleteResult = - AfternoteDetailDeleteResult.Failed( - messageRes = R.string.afternote_detail_delete_failed, - ), - ) - } + dispatch( + AfternoteDetailReducerEvent.DeleteFinished( + AfternoteDetailDeleteResult.Failed(R.string.afternote_detail_delete_failed), + ), + ) } } } - fun onDeleteResultConsumed() { - updateSuccess { it.copy(deleteResult = null) } - } - // endregion // region State shaping @@ -267,18 +279,7 @@ class AfternoteDetailViewModel /** 늦게 도착한 작성자 표시명을 반영한다. 아직 Loading 이면 필드에만 남아 다음 Success 에 실린다. */ private fun applyAuthorDisplayName(name: String) { authorDisplayName = name - updateSuccess { it.copy(authorDisplayName = name) } - } - - /** 삭제 진행·결과는 Success 에만 존재하므로, 그 외 상태에서는 갱신하지 않는다. */ - private fun updateSuccess(transform: (AfternoteDetailUiState.Success) -> AfternoteDetailUiState) { - _uiState.update { current -> - if (current is AfternoteDetailUiState.Success) { - transform(current) - } else { - current - } - } + dispatch(AfternoteDetailReducerEvent.AuthorNameChanged(name)) } // endregion @@ -288,3 +289,8 @@ class AfternoteDetailViewModel fun create(route: AfternoteRoute.DetailRoute): AfternoteDetailViewModel } } + +/** 상세 재조회가 삭제 진행·미소비 결과를 덮지 않도록 Success의 해당 필드만 바꾼다. */ +private inline fun AfternoteDetailUiState.withSuccess( + transform: (AfternoteDetailUiState.Success) -> AfternoteDetailUiState, +): AfternoteDetailUiState = if (this is AfternoteDetailUiState.Success) transform(this) else this diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt new file mode 100644 index 0000000000..6a0e9a2fd9 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt @@ -0,0 +1,29 @@ +package com.afternote.feature.afternote.presentation.editor.memorial + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay + +internal sealed interface AddSongIntent : MviIntent { + data class Search( + val query: String, + ) : AddSongIntent + + data object ConsumeError : AddSongIntent +} + +internal sealed interface AddSongReducerEvent : ReducerEvent { + data class QueryChanged( + val query: String, + ) : AddSongReducerEvent + + data object SearchStarted : AddSongReducerEvent + + data class SongsLoaded( + val songs: List, + ) : AddSongReducerEvent + + data object SearchFailed : AddSongReducerEvent + + data object ErrorConsumed : AddSongReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt index c6c8b708c7..0a5665f424 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt @@ -1,9 +1,15 @@ package com.afternote.feature.afternote.presentation.editor.memorial +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveSignal +import kotlinx.coroutines.launch /** * 노래 추가 Entry. @@ -11,7 +17,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle * ViewModel에서 데이터를 로드·가공하고, Screen에 전달만 합니다. */ @Composable -fun AddSongEntry( +internal fun AddSongEntry( viewModel: AddSongViewModel, onBackClick: () -> Unit, onSongsAdded: (List) -> Unit, @@ -19,10 +25,21 @@ fun AddSongEntry( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current + val scope = rememberCoroutineScope() + ObserveSignal( + signal = uiState.errorRes, + consumed = AddSongIntent.ConsumeError, + onIntent = viewModel::onIntent, + ) { errorRes -> + scope.launch { snackbarHostState.showSnackbar(resources.getString(errorRes), withDismissAction = true) } + } + AddSongScreen( uiState = uiState, - onSearchQueryChange = viewModel::onSearchQueryChange, - onErrorConsumed = viewModel::onErrorConsumed, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, onBackClick = onBackClick, onSongsAdded = onSongsAdded, modifier = modifier, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt index a90bc843d2..0822c5699e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt @@ -7,10 +7,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -21,39 +18,16 @@ import com.afternote.feature.afternote.presentation.shared.detail.SongPlaylistSc import com.afternote.feature.afternote.presentation.shared.detail.SongSearchSection import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay -/** - * 노래 추가하기 화면 (API 검색 연동). - * - * 공용 부품([SongPlaylistScaffold] + selectable 본문 [SelectableSongListBody])을 직접 조립하고, 이 기능 - * 고유의 것(VM 상태·검색 실패 Snackbar·PlaylistSongDisplay↔Song 매핑)을 얹는 소비자 계층이다. - * - * ViewModel 의존성 없이 순수하게 UI만 그립니다. [AddSongUiState.errorRes] 는 문자열 리소스 ID 라 - * UI 레이어가 [stringResource] 로 해석한 뒤 Snackbar 표출 → [onErrorConsumed] 로 VM 에 nullify - * 신호. VM 이 Android Framework (Context/Resources) 를 의존하지 않도록 string resolve 는 본 - * 레이어에서만 수행 (#267). 예외 원문을 실어 오던 갈래는 제거했다 — 검색 실패는 원인과 무관하게 - * 고정 안내 문구로만 노출한다 (#664). Snackbar 채택은 repo convention (Toast 2건 vs Snackbar 19건) + - * `showSnackbar` suspend 큐 의미로 같은 리소스 ID 가 연속 발화해도 표출 누락 회피. - */ +/** 검색 결과를 그린다. 상태 수집과 실패 신호 소비는 [AddSongEntry]가 담당한다. */ @Composable -fun AddSongScreen( +internal fun AddSongScreen( uiState: AddSongUiState, - onSearchQueryChange: (String) -> Unit, - onErrorConsumed: () -> Unit, + onIntent: (AddSongIntent) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onBackClick: () -> Unit, onSongsAdded: (List) -> Unit, modifier: Modifier = Modifier, ) { - val currentOnErrorConsumed by rememberUpdatedState(onErrorConsumed) - val snackbarHostState = remember { SnackbarHostState() } - - uiState.errorRes?.let { errorRes -> - val message = stringResource(errorRes) - LaunchedEffect(errorRes) { - snackbarHostState.showSnackbar(message = message, withDismissAction = true) - currentOnErrorConsumed() - } - } - Box(modifier = modifier.fillMaxSize()) { SongPlaylistScaffold( title = stringResource(R.string.afternote_editor_playlist_add_screen_title), @@ -70,7 +44,7 @@ fun AddSongScreen( header = { SongSearchSection( searchQuery = uiState.searchQuery, - onSearchQueryChange = onSearchQueryChange, + onSearchQueryChange = { onIntent(AddSongIntent.Search(it)) }, ) }, initialSelectedSongKeys = emptySet(), diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt index 261448b412..db588e1ecf 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.editor.memorial import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay /** @@ -10,9 +11,9 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * 예외 원문·서버 응답 본문 같은 임의 문자열이 화면까지 실려 갈 경로를 타입 차원에서 없앤다 (#664). * Composable 측에서 `stringResource` 로 변환. */ -data class AddSongUiState( +internal data class AddSongUiState( val songs: List = emptyList(), val searchQuery: String = "", val isLoading: Boolean = false, @param:StringRes val errorRes: Int? = null, -) +) : UiState diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt index 01fa2704ce..f92b900f4c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.editor.memorial -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -10,10 +10,7 @@ import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFai import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -21,56 +18,72 @@ import kotlin.time.Duration.Companion.milliseconds private const val SEARCH_DEBOUNCE_MS = 300L @HiltViewModel -class AddSongViewModel +internal class AddSongViewModel @Inject constructor( private val musicSearchRepository: MusicSearchRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(AddSongUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(AddSongUiState()) { private var searchJob: Job? = null - fun onSearchQueryChange(query: String) { - _uiState.update { it.copy(searchQuery = query, errorRes = null) } + override fun onIntent(intent: AddSongIntent) { + when (intent) { + is AddSongIntent.Search -> search(intent.query) + AddSongIntent.ConsumeError -> dispatch(AddSongReducerEvent.ErrorConsumed) + } + } + + private fun search(query: String) { + dispatch(AddSongReducerEvent.QueryChanged(query)) searchJob?.cancel() searchJob = viewModelScope.launch { delay(SEARCH_DEBOUNCE_MS.milliseconds) val trimmed = query.trim() if (trimmed.isEmpty()) { - _uiState.update { it.copy(songs = emptyList(), isLoading = false) } + dispatch(AddSongReducerEvent.SongsLoaded(emptyList())) return@launch } - _uiState.update { it.copy(isLoading = true) } - musicSearchRepository - .search(trimmed) + dispatch(AddSongReducerEvent.SearchStarted) + val result = musicSearchRepository.search(trimmed) + ensureActive() + result .onSuccess { list -> - _uiState.update { - it.copy( - songs = list.map { item -> item.toDisplay() }, - isLoading = false, - errorRes = null, - ) - } - }.onFailure { e -> - // 예외 원문은 서버 5xx 본문(내부 SQL)·역직렬화 예외(응답 원문 발췌·DTO 클래스명)를 - // 그대로 실어 오므로 화면에 싣지 않는다. 사용자에겐 원인과 무관하게 고정 안내만 노출. - errorReporter.recordAfternoteFailure(AfternoteFailureStage.MUSIC_SEARCH, e) - _uiState.update { - it.copy( - songs = emptyList(), - isLoading = false, - errorRes = R.string.afternote_editor_search_failed_generic, - ) - } + dispatch(AddSongReducerEvent.SongsLoaded(list.map { it.toDisplay() })) + }.onFailure { error -> + errorReporter.recordAfternoteFailure(AfternoteFailureStage.MUSIC_SEARCH, error) + dispatch(AddSongReducerEvent.SearchFailed) } } } - /** UI 가 [AddSongUiState.errorRes] 를 사용자에게 노출 직후 호출 → state nullify. */ - fun onErrorConsumed() { - _uiState.update { it.copy(errorRes = null) } - } + override fun reduce( + state: AddSongUiState, + event: AddSongReducerEvent, + ): AddSongUiState = + when (event) { + is AddSongReducerEvent.QueryChanged -> { + state.copy(searchQuery = event.query, errorRes = null) + } + + AddSongReducerEvent.SearchStarted -> { + state.copy(isLoading = true) + } + + is AddSongReducerEvent.SongsLoaded -> { + state.copy(songs = event.songs, isLoading = false, errorRes = null) + } + + AddSongReducerEvent.SearchFailed -> { + state.copy( + songs = emptyList(), + isLoading = false, + errorRes = R.string.afternote_editor_search_failed_generic, + ) + } + + AddSongReducerEvent.ErrorConsumed -> { + state.copy(errorRes = null) + } + } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt new file mode 100644 index 0000000000..73c4fdefe9 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt @@ -0,0 +1,34 @@ +package com.afternote.feature.afternote.presentation.editor.receiver + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface SelectReceiverIntent : MviIntent { + data class ApplyPreselection( + val receiverIds: List, + ) : SelectReceiverIntent + + data object Refresh : SelectReceiverIntent + + data class ToggleReceiver( + val receiverId: Long, + ) : SelectReceiverIntent +} + +internal sealed interface SelectReceiverReducerEvent : ReducerEvent { + data class PreselectionApplied( + val receiverIds: List, + ) : SelectReceiverReducerEvent + + data object Loading : SelectReceiverReducerEvent + + data class ReceiversLoaded( + val receivers: List, + ) : SelectReceiverReducerEvent + + data object LoadFailed : SelectReceiverReducerEvent + + data class ReceiverToggled( + val receiverId: Long, + ) : SelectReceiverReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt index cc240632bc..7f2f2f8422 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt @@ -27,13 +27,13 @@ internal fun AfternoteSelectReceiverNavigation( // 진입 시점의 폼에 담겨 있던 수신자만 체크한다. 회전·재진입으로 컴포지션이 새로 만들어지면 이 효과도 // 다시 돌지만, ViewModel 이 최초 1회만 받아 사용자가 푼 체크가 되살아나지 않는다. - LaunchedEffect(Unit) { viewModel.applyPreselection(preselectedReceiverIds) } + LaunchedEffect(Unit) { viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(preselectedReceiverIds)) } SelectReceiverScreen( uiState = uiState, onBackClick = onPopBackStack, - onReceiverToggle = viewModel::toggleReceiverSelection, - onRetryClick = viewModel::refresh, + onReceiverToggle = { viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(it)) }, + onRetryClick = { viewModel.onIntent(SelectReceiverIntent.Refresh) }, onConfirmClick = onReceiversConfirmed, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt index 14dd616ffe..e187dd41c9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt @@ -1,24 +1,21 @@ package com.afternote.feature.afternote.presentation.editor.receiver import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject /** 수신자 선택 화면 UI 상태. */ -data class SelectReceiverUiState( +internal data class SelectReceiverUiState( val isLoading: Boolean = false, val loadFailed: Boolean = false, /** 서버에서 불러온 내 수신자 목록 — 설정 › 수신자 관리에서 등록한 그 사람들. 화면의 행이다. */ @@ -32,7 +29,7 @@ data class SelectReceiverUiState( * 빈 목록이 «아무도 선택하지 않음» 이다. null 로 없음을 표현하지 않는다 (#1426). */ val selectedReceiverIds: List = emptyList(), -) +) : UiState /** * 애프터노트 에디터의 수신자 선택 화면 ViewModel (#540). @@ -46,24 +43,71 @@ data class SelectReceiverUiState( * 화면에서 체크를 푼 수신자는 폼에서도 빠진다. * * 화면 내 선택과 «폼 수신자는 이미 넣었다» 는 사실은 [SavedStateHandle] 에 적어 둔다 (#1427). 백그라운드에서 - * 프로세스가 재생성돼도 선택이 그대로 복원되고, 복귀 시 다시 도는 [applyPreselection] 이 사용자가 푼 + * 프로세스가 재생성돼도 선택이 복원되고, 복귀 시 프리필이 사용자가 푼 * 폼 수신자를 되살리지 않는다. */ @HiltViewModel -class SelectReceiverViewModel +internal class SelectReceiverViewModel @Inject constructor( private val userReceiverRepository: UserReceiverRepository, private val errorReporter: ErrorReporter, private val savedStateHandle: SavedStateHandle, - ) : ViewModel() { - private val _uiState = - MutableStateFlow( - SelectReceiverUiState( - selectedReceiverIds = savedStateHandle.get(SELECTED_RECEIVER_IDS_STATE_KEY)?.toList().orEmpty(), - ), - ) - val uiState: StateFlow = _uiState.asStateFlow() + ) : MviViewModel( + SelectReceiverUiState( + selectedReceiverIds = savedStateHandle.get(SELECTED_RECEIVER_IDS_STATE_KEY)?.toList().orEmpty(), + ), + ) { + override fun onIntent(intent: SelectReceiverIntent) { + when (intent) { + is SelectReceiverIntent.ApplyPreselection -> applyPreselection(intent.receiverIds) + SelectReceiverIntent.Refresh -> refresh() + is SelectReceiverIntent.ToggleReceiver -> toggleReceiverSelection(intent.receiverId) + } + } + + override fun reduce( + state: SelectReceiverUiState, + event: SelectReceiverReducerEvent, + ): SelectReceiverUiState = + when (event) { + is SelectReceiverReducerEvent.PreselectionApplied -> { + state.copy( + selectedReceiverIds = + ( + event.receiverIds + + state.selectedReceiverIds + ).distinct(), + ) + } + + SelectReceiverReducerEvent.Loading -> { + state.copy(isLoading = true, loadFailed = false) + } + + is SelectReceiverReducerEvent.ReceiversLoaded -> { + state.copy( + isLoading = false, + receivers = event.receivers, + selectedReceiverIds = state.selectedReceiverIds.filter { selected -> event.receivers.any { it.id == selected } }, + ) + } + + SelectReceiverReducerEvent.LoadFailed -> { + state.copy(isLoading = false, loadFailed = true) + } + + is SelectReceiverReducerEvent.ReceiverToggled -> { + state.copy( + selectedReceiverIds = + if (event.receiverId in state.selectedReceiverIds) { + state.selectedReceiverIds - event.receiverId + } else { + state.selectedReceiverIds + event.receiverId + }, + ) + } + } private var isPreselectionApplied: Boolean get() = savedStateHandle[PRESELECTION_APPLIED_STATE_KEY] ?: false @@ -86,52 +130,33 @@ class SelectReceiverViewModel * 폼의 id 는 화면이 뜨자마자 오고 목록은 서버 응답이라 그보다 늦다. 목록에 없는 id 는 [refresh] 가 * 목록을 받으며 뺀다. */ - fun applyPreselection(formReceiverIds: List) { + private fun applyPreselection(formReceiverIds: List) { if (isPreselectionApplied) return isPreselectionApplied = true if (formReceiverIds.isEmpty()) return - _uiState.update { state -> - state.copy(selectedReceiverIds = (formReceiverIds + state.selectedReceiverIds).distinct()) - } - persistSelection(_uiState.value.selectedReceiverIds) + dispatch(SelectReceiverReducerEvent.PreselectionApplied(formReceiverIds)) + persistSelection(currentState.selectedReceiverIds) } /** 수신자 목록을 (재)조회한다. 실패 화면의 "다시 시도" 도 여기로 온다. */ - fun refresh() { + private fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, loadFailed = false) } + dispatch(SelectReceiverReducerEvent.Loading) runCatchingCancellable { userReceiverRepository.getReceivers() } .onSuccess { receivers -> - _uiState.update { state -> - state.copy( - isLoading = false, - receivers = receivers.toAfternoteEditorReceivers(), - // 목록에 없는 수신자 선택은 해제한다 — 폼에서 넘어왔지만 설정에서 지워진 id, - // 재조회로 사라진 id. 남겨 두면 완료 버튼이 이미 없는 id 를 에디터로 돌려보낸다. - selectedReceiverIds = - state.selectedReceiverIds.filter { selected -> - receivers.any { it.receiverId == selected } - }, - ) - } - persistSelection(_uiState.value.selectedReceiverIds) + dispatch(SelectReceiverReducerEvent.ReceiversLoaded(receivers.toAfternoteEditorReceivers())) + persistSelection(currentState.selectedReceiverIds) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVER_SELECT_LOAD, e) - _uiState.update { it.copy(isLoading = false, loadFailed = true) } + dispatch(SelectReceiverReducerEvent.LoadFailed) } } } /** 탭한 수신자를 선택 목록에 더하고, 이미 선택된 수신자를 다시 탭하면 그 항목만 뺀다 (#1426). */ - fun toggleReceiverSelection(receiverId: Long) { - _uiState.update { state -> - val selected = state.selectedReceiverIds - state.copy( - selectedReceiverIds = - if (receiverId in selected) selected - receiverId else selected + receiverId, - ) - } - persistSelection(_uiState.value.selectedReceiverIds) + private fun toggleReceiverSelection(receiverId: Long) { + dispatch(SelectReceiverReducerEvent.ReceiverToggled(receiverId)) + persistSelection(currentState.selectedReceiverIds) } private fun persistSelection(receiverIds: List) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt new file mode 100644 index 0000000000..12391bc0d2 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.afternote.presentation.home + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.domain.AfternoteType + +internal data class AfternoteHomeUiState( + val selectedType: AfternoteType? = null, +) : UiState + +internal sealed interface AfternoteHomeIntent : MviIntent { + data class SelectType( + val type: AfternoteType?, + ) : AfternoteHomeIntent + + data class ListLoadFailed( + val throwable: Throwable, + ) : AfternoteHomeIntent + + data object ListLoadSucceeded : AfternoteHomeIntent +} + +internal sealed interface AfternoteHomeReducerEvent : ReducerEvent { + data class TypeSelected( + val type: AfternoteType?, + ) : AfternoteHomeReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt index c003437ab3..a9bcf4f309 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt @@ -26,13 +26,14 @@ import com.afternote.feature.afternote.presentation.R * 중복 억제는 VM 이 맡는다. */ @Composable -fun AfternoteHomeEntry( +internal fun AfternoteHomeEntry( navigateToDetail: (Long) -> Unit, navigateToAdd: (AfternoteType) -> Unit, onSettingClick: () -> Unit, viewModel: AfternoteHomeViewModel = hiltViewModel(), ) { - val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedType = uiState.selectedType val items = viewModel.pagedAfternotes.collectAsLazyPagingItems() val snackbarHostState = remember { SnackbarHostState() } @@ -40,7 +41,7 @@ fun AfternoteHomeEntry( val appendErrorMessage = stringResource(R.string.afternote_home_append_error) LaunchedEffect(appendState) { if (appendState is LoadState.Error) { - viewModel.onListLoadFailed(appendState.error) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(appendState.error)) snackbarHostState.showSnackbar(message = appendErrorMessage) } } @@ -48,10 +49,10 @@ fun AfternoteHomeEntry( val refreshState = items.loadState.refresh LaunchedEffect(refreshState) { when (refreshState) { - is LoadState.Error -> viewModel.onListLoadFailed(refreshState.error) + is LoadState.Error -> viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(refreshState.error)) // 성공한 로드가 실패 구간을 닫는다 — 다음 실패는 새 사건으로 다시 기록된다. - is LoadState.NotLoading -> viewModel.onListLoadSucceeded() + is LoadState.NotLoading -> viewModel.onIntent(AfternoteHomeIntent.ListLoadSucceeded) is LoadState.Loading -> Unit } @@ -61,7 +62,7 @@ fun AfternoteHomeEntry( items = items, selectedType = selectedType, snackbarHostState = snackbarHostState, - onTypeSelected = viewModel::selectTab, + onTypeSelected = { viewModel.onIntent(AfternoteHomeIntent.SelectType(it)) }, onListItemClick = { id, type -> when (type) { AfternoteType.SOCIAL_NETWORK, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt index d533f5a94b..c5b03ac398 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt @@ -1,12 +1,11 @@ package com.afternote.feature.afternote.presentation.home -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -16,9 +15,7 @@ import com.afternote.feature.afternote.presentation.shared.util.getIconResForSer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -28,30 +25,39 @@ import javax.inject.Inject * CUD 후 목록 갱신은 Repository 내부에서 자동 처리되므로 수동 refresh는 불필요하다. */ @HiltViewModel -class AfternoteHomeViewModel +internal class AfternoteHomeViewModel @Inject constructor( private val afternoteRepository: AfternoteRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - /** 선택된 종류 필터. `null` 은 전체다. */ - private val _selectedType = MutableStateFlow(null) - val selectedType: StateFlow = _selectedType.asStateFlow() - + ) : MviViewModel(AfternoteHomeUiState()) { @OptIn(ExperimentalCoroutinesApi::class) val pagedAfternotes: Flow> = - _selectedType + uiState + .map { it.selectedType } + .distinctUntilChanged() .flatMapLatest { type -> afternoteRepository .getPagedAfternotes(type) .map { pagingData -> pagingData.map { it.toUiModel() } } }.cachedIn(viewModelScope) - fun selectTab(tab: AfternoteType?) { - if (_selectedType.value == tab) return - _selectedType.value = tab + override fun onIntent(intent: AfternoteHomeIntent) { + when (intent) { + is AfternoteHomeIntent.SelectType -> dispatch(AfternoteHomeReducerEvent.TypeSelected(intent.type)) + is AfternoteHomeIntent.ListLoadFailed -> onListLoadFailed(intent.throwable) + AfternoteHomeIntent.ListLoadSucceeded -> onListLoadSucceeded() + } } + override fun reduce( + state: AfternoteHomeUiState, + event: AfternoteHomeReducerEvent, + ): AfternoteHomeUiState = + when (event) { + is AfternoteHomeReducerEvent.TypeSelected -> state.copy(selectedType = event.type) + } + /** * 마지막으로 기록한 목록 실패의 예외 타입. 같은 장애가 이어지는 동안 재기록을 막는 기준이다 — * 예외 «인스턴스» 로 비교하면 Paging 이 재시도마다 새 예외를 만들어 중복 억제가 성립하지 않는다. @@ -67,7 +73,7 @@ class AfternoteHomeViewModel * 그래서 «장애가 이어지는 동안 한 번» 만 기록하고, 로드가 성공하면([onListLoadSucceeded]) * 다음 실패를 새 사건으로 다시 받는다. */ - fun onListLoadFailed(throwable: Throwable) { + private fun onListLoadFailed(throwable: Throwable) { val failureType = throwable.javaClass.name if (lastReportedFailureType == failureType) return lastReportedFailureType = failureType @@ -75,7 +81,7 @@ class AfternoteHomeViewModel } /** 목록 로드가 성공해 실패 구간이 끝났음을 알린다 — 다음 실패는 새 사건으로 기록된다. */ - fun onListLoadSucceeded() { + private fun onListLoadSucceeded() { lastReportedFailureType = null } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt index 85618dec5f..d13ccd6d95 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt @@ -1,21 +1,27 @@ package com.afternote.feature.afternote.presentation.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.feature.afternote.presentation.AfternoteHostIntent import com.afternote.feature.afternote.presentation.AfternoteHostViewModel import com.afternote.feature.afternote.presentation.detail.AfternoteDetailNavigation import com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel import com.afternote.feature.afternote.presentation.home.AfternoteHomeNavigation import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import com.afternote.feature.afternote.presentation.shared.fingerprint.AfternoteFingerprintLoginNavigation +import kotlinx.coroutines.awaitCancellation /** * 애프터노트 작성자 피처가 소유하는 로컬 Navigation 3 스택. @@ -55,7 +61,19 @@ public fun AfternoteNavHost( entryProvider { entry { AfternoteLightTheme { - val isPasskeyRegistered by hostViewModel.isPasskeyRegistered.collectAsStateWithLifecycle() + val hostState by hostViewModel.uiState.collectAsStateWithLifecycle() + val isPasskeyRegistered = hostState.isPasskeyRegistered + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(hostViewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + hostViewModel.onIntent(AfternoteHostIntent.ObserveProfile) + try { + awaitCancellation() + } finally { + hostViewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + } + } + } AfternoteFingerprintLoginNavigation( isPasskeyRegistered = isPasskeyRegistered, onAuthenticationSuccess = actions::replaceFingerprintLoginWithAfternoteHome, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt new file mode 100644 index 0000000000..4c78df5f1f --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt @@ -0,0 +1,22 @@ +package com.afternote.feature.afternote.presentation.receiver.afternotelist + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.domain.AfternoteType + +internal data class ReceiverAfternoteHomeUiState( + val selectedTab: AfternoteType? = null, +) : UiState + +internal sealed interface ReceiverAfternoteHomeIntent : MviIntent { + data class SelectType( + val type: AfternoteType?, + ) : ReceiverAfternoteHomeIntent +} + +internal sealed interface ReceiverAfternoteHomeReducerEvent : ReducerEvent { + data class TypeSelected( + val type: AfternoteType?, + ) : ReceiverAfternoteHomeReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt index 4181b29014..cdfed6651b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt @@ -7,9 +7,11 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.home.AfternoteHomeScreen +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel import com.afternote.core.ui.R as CoreUiR /** @@ -28,13 +30,25 @@ import com.afternote.core.ui.R as CoreUiR * 수신자는 로그인 사용자가 아니고 남길 기록을 쓰는 주체도 아니다. */ @Composable -fun ReceiverAfternoteHomeEntry( +internal fun ReceiverAfternoteHomeEntry( navigateToDetail: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: ReceiverAfternoteHomeViewModel = hiltViewModel(), ) { - val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val items = viewModel.pagedAfternotes.collectAsLazyPagingItems() + ReceiverAfternoteHomeContent(uiState, items, viewModel::onIntent, navigateToDetail, modifier) +} + +@Composable +private fun ReceiverAfternoteHomeContent( + state: ReceiverAfternoteHomeUiState, + items: LazyPagingItems, + onIntent: (ReceiverAfternoteHomeIntent) -> Unit, + navigateToDetail: (Long) -> Unit, + modifier: Modifier, +) { + val selectedTab = state.selectedTab // 이미 그려 둔 목록이 있으면 유지한다 — 전면 교체는 보여 줄 것이 전무할 때만 (작성자 화면과 같은 규칙). val refreshState = items.loadState.refresh val listError = @@ -70,7 +84,7 @@ fun ReceiverAfternoteHomeEntry( AfternoteHomeScreen( items = items, selectedType = selectedTab, - onTypeSelected = viewModel::selectTab, + onTypeSelected = { onIntent(ReceiverAfternoteHomeIntent.SelectType(it)) }, onListItemClick = { id, _ -> navigateToDetail(id) }, headerDescription = stringResource(R.string.afternote_receiver_afternote_list_header_description), // NEXT STEP 은 «내가 남길 기록» 을 재촉하는 발신자용 카드다. 수신자 목록에는 없다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt index db3786537c..7e9b1148f8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt @@ -1,12 +1,11 @@ package com.afternote.feature.afternote.presentation.receiver.afternotelist -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map -import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel import com.afternote.feature.afternote.presentation.shared.util.getIconResForService import com.afternote.feature.receiver.domain.model.AfterNoteListItem @@ -14,9 +13,7 @@ import com.afternote.feature.receiver.domain.repository.ReceiverRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -29,18 +26,18 @@ import javax.inject.Inject * 서버 파라미터가 없어 클라이언트 사이드 [PagingData.filter]로 적용한다. */ @HiltViewModel -class ReceiverAfternoteHomeViewModel +internal class ReceiverAfternoteHomeViewModel @Inject constructor( private val receiverRepository: ReceiverRepository, - ) : ViewModel() { - /** 선택된 종류 필터. `null` 은 전체다. */ - private val _selectedTab = MutableStateFlow(null) - val selectedTab: StateFlow = _selectedTab.asStateFlow() - + ) : MviViewModel( + ReceiverAfternoteHomeUiState(), + ) { @OptIn(ExperimentalCoroutinesApi::class) val pagedAfternotes: Flow> = - _selectedTab + uiState + .map { it.selectedTab } + .distinctUntilChanged() .flatMapLatest { tab -> receiverRepository .getPagedReceivedAfternotes() @@ -51,10 +48,19 @@ class ReceiverAfternoteHomeViewModel } }.cachedIn(viewModelScope) - fun selectTab(tab: AfternoteType?) { - if (_selectedTab.value == tab) return - _selectedTab.value = tab + override fun onIntent(intent: ReceiverAfternoteHomeIntent) { + when (intent) { + is ReceiverAfternoteHomeIntent.SelectType -> dispatch(ReceiverAfternoteHomeReducerEvent.TypeSelected(intent.type)) + } } + + override fun reduce( + state: ReceiverAfternoteHomeUiState, + event: ReceiverAfternoteHomeReducerEvent, + ): ReceiverAfternoteHomeUiState = + when (event) { + is ReceiverAfternoteHomeReducerEvent.TypeSelected -> state.copy(selectedTab = event.type) + } } /** 카드 주 텍스트는 서비스명이, 미등록 서비스 아이콘과 필터 탭은 종류가 결정한다. */ diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt new file mode 100644 index 0000000000..8f8289a37f --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.afternote.presentation.receiver.detail + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ReceivedAfternoteDetailIntent : MviIntent { + data object Retry : ReceivedAfternoteDetailIntent + + data object RefreshOnReturn : ReceivedAfternoteDetailIntent +} + +internal sealed interface ReceivedAfternoteDetailReducerEvent : ReducerEvent { + data object Loading : ReceivedAfternoteDetailReducerEvent + + data class ContentLoaded( + val id: Long, + val content: ReceivedDetailContentUiModel, + ) : ReceivedAfternoteDetailReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : ReceivedAfternoteDetailReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt index ef241af4c6..2f248b99dc 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt @@ -2,7 +2,6 @@ package com.afternote.feature.afternote.presentation.receiver.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -27,7 +26,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErro * 실패를 이쪽으로 보내면 서버·네트워크 오류가 "디자인 예정" 으로 표시되고 재시도 수단도 사라진다 (#713). */ @Composable -fun ReceivedAfternoteDetailRoute( +internal fun ReceivedAfternoteDetailRoute( onNavigateBack: () -> Unit, onNavigateToFullList: () -> Unit, onNavigateToPlaylist: (afternoteId: Long) -> Unit, @@ -43,9 +42,20 @@ fun ReceivedAfternoteDetailRoute( // 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME 스킵(진입은 init 로드가 // 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다 — 결선부는 무조건 부른다. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) } + ReceivedAfternoteDetailContent(uiState, viewModel::onIntent, onNavigateBack, onNavigateToFullList, onNavigateToPlaylist) +} + +@Composable +private fun ReceivedAfternoteDetailContent( + uiState: ReceivedAfternoteDetailUiState, + onIntent: (ReceivedAfternoteDetailIntent) -> Unit, + onNavigateBack: () -> Unit, + onNavigateToFullList: () -> Unit, + onNavigateToPlaylist: (Long) -> Unit, +) { when (val state = uiState) { ReceivedAfternoteDetailUiState.Loading -> { LoadingBody() @@ -55,7 +65,7 @@ fun ReceivedAfternoteDetailRoute( DetailLoadErrorContent( messageRes = state.messageRes, onBackClick = onNavigateBack, - onRetryClick = viewModel::retry, + onRetryClick = { onIntent(ReceivedAfternoteDetailIntent.Retry) }, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt index a2a58e17ff..48821c0e69 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt @@ -1,8 +1,9 @@ package com.afternote.feature.afternote.presentation.receiver.detail import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState -sealed interface ReceivedAfternoteDetailUiState { +internal sealed interface ReceivedAfternoteDetailUiState : UiState { data object Loading : ReceivedAfternoteDetailUiState data class Success( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt index 9c5d0b93b0..d192cf2d4e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.receiver.detail -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -14,10 +14,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -32,19 +28,48 @@ import kotlinx.coroutines.launch * 수정·삭제·작성자 표시명·수신자 목록은 보유하지 않는다. */ @HiltViewModel(assistedFactory = ReceivedAfternoteDetailViewModel.Factory::class) -class ReceivedAfternoteDetailViewModel +internal class ReceivedAfternoteDetailViewModel @AssistedInject constructor( @Assisted private val route: ReceivedAfternoteRoute.DetailRoute, private val receiverRepository: ReceiverRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel( + ReceivedAfternoteDetailUiState.Loading, + ) { private val afternoteIdFromNav: Long = route.afternoteId - private val _uiState = - MutableStateFlow(ReceivedAfternoteDetailUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: ReceivedAfternoteDetailIntent) { + when (intent) { + ReceivedAfternoteDetailIntent.Retry -> retry() + ReceivedAfternoteDetailIntent.RefreshOnReturn -> refreshOnReturn() + } + } + + override fun reduce( + state: ReceivedAfternoteDetailUiState, + event: ReceivedAfternoteDetailReducerEvent, + ): ReceivedAfternoteDetailUiState = + when (event) { + ReceivedAfternoteDetailReducerEvent.Loading -> { + ReceivedAfternoteDetailUiState.Loading + } + + is ReceivedAfternoteDetailReducerEvent.ContentLoaded -> { + ReceivedAfternoteDetailUiState.Success(detailId = event.id, contentUiModel = event.content) + } + + is ReceivedAfternoteDetailReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is ReceivedAfternoteDetailUiState.Success + ) { + state + } else { + ReceivedAfternoteDetailUiState.Error(R.string.afternote_detail_load_error) + } + } + } /** 진행 중인 상세 조회 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ private var loadJob: Job? = null @@ -62,7 +87,7 @@ class ReceivedAfternoteDetailViewModel loadDetail(afternoteIdFromNav) } - fun retry() { + private fun retry() { loadDetail(afternoteIdFromNav) } @@ -73,7 +98,7 @@ class ReceivedAfternoteDetailViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -91,7 +116,7 @@ class ReceivedAfternoteDetailViewModel loadJob = viewModelScope.launch { if (showsLoading) { - _uiState.value = ReceivedAfternoteDetailUiState.Loading + dispatch(ReceivedAfternoteDetailReducerEvent.Loading) } val result = receiverRepository.getReceivedAfternoteDetail(afternoteId = afternoteId) // 새 로드가 이 Job 을 취소했다면 화면은 그쪽이 결정한다. repository 는 @@ -101,23 +126,13 @@ class ReceivedAfternoteDetailViewModel ensureActive() result .onSuccess { detail -> - _uiState.value = - ReceivedAfternoteDetailUiState.Success( - detailId = afternoteId, - contentUiModel = detail.toReceivedDetailContentUiModel(), - ) + dispatch( + ReceivedAfternoteDetailReducerEvent.ContentLoaded(afternoteId, detail.toReceivedDetailContentUiModel()), + ) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_DETAIL_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is ReceivedAfternoteDetailUiState.Success) { - current - } else { - ReceivedAfternoteDetailUiState.Error( - messageRes = R.string.afternote_detail_load_error, - ) - } - } + dispatch(ReceivedAfternoteDetailReducerEvent.LoadFailed(keepsStateOnFailure)) } } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt index 6dfb59a003..37b8ab0a5e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt @@ -1,26 +1,19 @@ package com.afternote.feature.afternote.presentation.receiver.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureStackBoundary import com.afternote.feature.afternote.presentation.navigation.AfternoteLightTheme import com.afternote.feature.afternote.presentation.receiver.afternotelist.ReceiverAfternoteHomeEntry import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen -import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistUiState +import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistRoute import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel -import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent /** * 수신 애프터노트(목록·상세·추억 플레이리스트)가 소유하는 로컬 Navigation 3 스택. @@ -72,36 +65,7 @@ public fun ReceivedAfternoteNavHost( hiltViewModel( creationCallback = { factory -> factory.create(key) }, ) - val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle() - - // 화면을 떠났다 돌아오면 다시 조회한다 — 백스택에 살아 있는 동안 옛 값이 남지 않게 - // 한다 (#701). 로딩을 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME - // 스킵(진입은 init 로드가 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다. - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - playlistViewModel.refreshOnReturn() - } - - when (val state = playlistUiState) { - ReceiverMemorialPlaylistUiState.Loading -> { - LoadingBody() - } - - is ReceiverMemorialPlaylistUiState.Error -> { - DetailLoadErrorContent( - messageRes = state.messageRes, - onBackClick = actions::popBack, - onRetryClick = playlistViewModel::retry, - ) - } - - is ReceiverMemorialPlaylistUiState.Success -> { - MemorialPlaylistScreen( - senderName = state.senderName, - songs = state.songs, - onBackClick = actions::popBack, - ) - } - } + ReceiverMemorialPlaylistRoute(viewModel = playlistViewModel, onBackClick = actions::popBack) } } }, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt new file mode 100644 index 0000000000..fa7eea5711 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt @@ -0,0 +1,22 @@ +package com.afternote.feature.afternote.presentation.receiver.playlist + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ReceiverMemorialPlaylistIntent : MviIntent { + data object Retry : ReceiverMemorialPlaylistIntent + + data object RefreshOnReturn : ReceiverMemorialPlaylistIntent +} + +internal sealed interface ReceiverMemorialPlaylistReducerEvent : ReducerEvent { + data object Loading : ReceiverMemorialPlaylistReducerEvent + + data class ContentLoaded( + val content: ReceiverMemorialPlaylistUiState.Success, + ) : ReceiverMemorialPlaylistReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : ReceiverMemorialPlaylistReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt new file mode 100644 index 0000000000..331495a4c1 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.afternote.presentation.receiver.playlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent + +@Composable +internal fun ReceiverMemorialPlaylistRoute( + viewModel: ReceiverMemorialPlaylistViewModel, + onBackClick: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) + } + ReceiverMemorialPlaylistContent(state, viewModel::onIntent, onBackClick) +} + +@Composable +private fun ReceiverMemorialPlaylistContent( + state: ReceiverMemorialPlaylistUiState, + onIntent: (ReceiverMemorialPlaylistIntent) -> Unit, + onBackClick: () -> Unit, +) { + when (state) { + ReceiverMemorialPlaylistUiState.Loading -> { + LoadingBody() + } + + is ReceiverMemorialPlaylistUiState.Error -> { + DetailLoadErrorContent( + messageRes = state.messageRes, + onBackClick = onBackClick, + onRetryClick = { onIntent(ReceiverMemorialPlaylistIntent.Retry) }, + ) + } + + is ReceiverMemorialPlaylistUiState.Success -> { + MemorialPlaylistScreen(senderName = state.senderName, songs = state.songs, onBackClick = onBackClick) + } + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt index 76770e43a7..b507e72a27 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt @@ -1,9 +1,10 @@ package com.afternote.feature.afternote.presentation.receiver.playlist import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay -sealed interface ReceiverMemorialPlaylistUiState { +internal sealed interface ReceiverMemorialPlaylistUiState : UiState { data object Loading : ReceiverMemorialPlaylistUiState data class Success( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt index 75de8acdaa..a66eaec293 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.receiver.playlist -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -14,10 +14,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -27,19 +23,48 @@ import kotlinx.coroutines.launch * `X-Auth-Code` 헤더는 ReceiverAuthInterceptor가 자동 부착합니다. */ @HiltViewModel(assistedFactory = ReceiverMemorialPlaylistViewModel.Factory::class) -class ReceiverMemorialPlaylistViewModel +internal class ReceiverMemorialPlaylistViewModel @AssistedInject constructor( @Assisted private val route: ReceivedAfternoteRoute.MemorialPlaylistRoute, private val receiverRepository: ReceiverRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel( + ReceiverMemorialPlaylistUiState.Loading, + ) { private val afternoteIdFromNav: Long = route.afternoteId - private val _uiState = - MutableStateFlow(ReceiverMemorialPlaylistUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: ReceiverMemorialPlaylistIntent) { + when (intent) { + ReceiverMemorialPlaylistIntent.Retry -> retry() + ReceiverMemorialPlaylistIntent.RefreshOnReturn -> refreshOnReturn() + } + } + + override fun reduce( + state: ReceiverMemorialPlaylistUiState, + event: ReceiverMemorialPlaylistReducerEvent, + ): ReceiverMemorialPlaylistUiState = + when (event) { + ReceiverMemorialPlaylistReducerEvent.Loading -> { + ReceiverMemorialPlaylistUiState.Loading + } + + is ReceiverMemorialPlaylistReducerEvent.ContentLoaded -> { + event.content + } + + is ReceiverMemorialPlaylistReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is ReceiverMemorialPlaylistUiState.Success + ) { + state + } else { + ReceiverMemorialPlaylistUiState.Error(R.string.afternote_receiver_memorial_playlist_load_error) + } + } + } /** 진행 중인 조회 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ private var loadJob: Job? = null @@ -57,7 +82,7 @@ class ReceiverMemorialPlaylistViewModel loadPlaylist(afternoteIdFromNav) } - fun retry() { + private fun retry() { loadPlaylist(afternoteIdFromNav) } @@ -68,7 +93,7 @@ class ReceiverMemorialPlaylistViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -84,7 +109,7 @@ class ReceiverMemorialPlaylistViewModel ) { loadJob?.cancel() if (showsLoading) { - _uiState.value = ReceiverMemorialPlaylistUiState.Loading + dispatch(ReceiverMemorialPlaylistReducerEvent.Loading) } loadJob = viewModelScope.launch { @@ -103,25 +128,20 @@ class ReceiverMemorialPlaylistViewModel albumImageUrl = song.coverUrl, ) }.orEmpty() - _uiState.value = - ReceiverMemorialPlaylistUiState.Success( - senderName = detail.senderName, - songs = songs, - memorialVideoUrl = playlist?.memorialVideoUrl, - memorialThumbnailUrl = playlist?.memorialThumbnailUrl, - ) + dispatch( + ReceiverMemorialPlaylistReducerEvent.ContentLoaded( + ReceiverMemorialPlaylistUiState.Success( + senderName = detail.senderName, + songs = songs, + memorialVideoUrl = playlist?.memorialVideoUrl, + memorialThumbnailUrl = playlist?.memorialThumbnailUrl, + ), + ), + ) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_PLAYLIST_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is ReceiverMemorialPlaylistUiState.Success) { - current - } else { - ReceiverMemorialPlaylistUiState.Error( - messageRes = R.string.afternote_receiver_memorial_playlist_load_error, - ) - } - } + dispatch(ReceiverMemorialPlaylistReducerEvent.LoadFailed(keepsStateOnFailure)) } } } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt deleted file mode 100644 index 08188dd016..0000000000 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.afternote.feature.afternote.presentation.detail - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.afternote.core.ui.theme.AfternoteTheme -import com.android.tools.screenshot.PreviewTest - -/** - * [DeleteInProgressOverlay] 는 상세 화면 위에 겹쳐 그리는 반투명 스크림이라 - * 하위 콘텐츠를 어둡게 덮는 alpha 효과가 핵심이다. 뒤에 더미 콘텐츠를 깔고 - * 그 위에 오버레이를 얹어 스크림 대비와 중앙 인디케이터를 함께 회귀 검증한다. - */ -@PreviewTest -@Preview(showBackground = true) -@Composable -internal fun deleteInProgressOverlayScreenshot() { - AfternoteTheme { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text(text = "상세 콘텐츠") - DeleteInProgressOverlay() - } - } -} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt index d613340591..dc892b50a0 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt @@ -25,8 +25,7 @@ internal fun addSongScreenScreenshot() { AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) @@ -45,8 +44,7 @@ internal fun addSongScreenCompactScreenshot() { AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png deleted file mode 100644 index b48725f94255f5989cdcc946584b4f2b64743db5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19392 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 OIC<>!(aOWYSN{vAuh#tl diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 44bb5bad1e..74adc55184 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -48,6 +48,7 @@ import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMedi import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.detail.AfternoteDetailDeleteResult +import com.afternote.feature.afternote.presentation.detail.AfternoteDetailIntent import com.afternote.feature.afternote.presentation.detail.AfternoteDetailUiState import com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel import com.afternote.feature.afternote.presentation.detail.DetailContentUiModel @@ -464,12 +465,12 @@ private fun AuthorDetailForDelete( result.messageRes ?: AfternoteFeatureR.string.afternote_detail_delete_failed, ) scope.launch { snackbarHostState.showSnackbar(message) } - viewModel.onDeleteResultConsumed() + viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) } is AfternoteDetailDeleteResult.Succeeded -> { deletedIds += result.id - viewModel.onDeleteResultConsumed() + viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) } null -> { @@ -494,7 +495,7 @@ private fun AuthorDetailForDelete( content = account.content, snackbarHostState = snackbarHostState, onEditClick = {}, - onDeleteConfirm = viewModel::deleteAfternote, + onDeleteConfirm = { viewModel.onIntent(AfternoteDetailIntent.Delete) }, ) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt new file mode 100644 index 0000000000..2c1b78e828 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt @@ -0,0 +1,61 @@ +package com.afternote.feature.afternote.presentation + +import com.afternote.core.domain.testing.FakeUserProfileCacheRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AfternoteHostViewModelTest { + @After + fun resetDispatcher() = Dispatchers.resetMain() + + @Test + fun `프로필은 화면 구독 때 시작하고 빠른 복귀는 기존 구독을 유지한다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = FakeUserProfileCacheRepository() + val viewModel = AfternoteHostViewModel(repository) + assertNull(viewModel.uiState.value.isPasskeyRegistered) + assertEquals(0, repository.isPasskeyRegisteredFlowCalls) + + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + runCurrent() + assertEquals(false, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(1, repository.passkeyRegisteredState.subscriptionCount.value) + + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + runCurrent() + advanceTimeBy(4_999) + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + repository.passkeyRegisteredState.value = true + runCurrent() + assertEquals(true, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(1, repository.isPasskeyRegisteredFlowCalls) + + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + assertEquals(0, repository.passkeyRegisteredState.subscriptionCount.value) + assertEquals(true, viewModel.uiState.value.isPasskeyRegistered) + + repository.passkeyRegisteredState.value = false + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + runCurrent() + assertEquals(false, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(2, repository.isPasskeyRegisteredFlowCalls) + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + advanceTimeBy(5_001) + runCurrent() + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt new file mode 100644 index 0000000000..b944299d21 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.activity.ComponentActivity +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteDetailContentTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `실제 상세 상태가 삭제 진행 표시를 올리고 완료하면 제거한다`() { + var state by mutableStateOf( + AfternoteDetailUiState.Success( + detailId = 73L, + contentUiModel = DetailContentUiModel.Memorial(MemorialDetailContent()), + authorDisplayName = "서영", + isDeleting = true, + ), + ) + val intents = mutableListOf() + var backClicks = 0 + composeRule.setContent { + AfternoteTheme { + AfternoteDetailContent( + uiState = state, + onIntent = intents::add, + snackbarHostState = SnackbarHostState(), + onNavigateBack = { backClicks++ }, + onNavigateToEditor = { _, _ -> }, + onVideoClick = {}, + ) + } + } + val deleting = composeRule.activity.getString(R.string.afternote_detail_deleting) + composeRule.onNodeWithText("추억 노트에 대한\n서영님의 기록").assertExists() + composeRule.onNodeWithContentDescription(deleting).assertIsDisplayed() + val back = composeRule.activity.getString(com.afternote.core.ui.R.string.core_ui_content_description_back) + composeRule.onNodeWithContentDescription(back).performTouchInput { click() } + composeRule.runOnIdle { + assertEquals(emptyList(), intents) + assertEquals(0, backClicks) + state = (state as AfternoteDetailUiState.Success).copy(isDeleting = false) + } + composeRule.onNodeWithContentDescription(deleting).assertDoesNotExist() + composeRule.onNodeWithContentDescription(back).performTouchInput { click() } + composeRule.runOnIdle { assertEquals(1, backClicks) } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt index 75ff275a14..09f80d8b9c 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt @@ -72,7 +72,7 @@ class AfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨). - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) assertEquals(listOf(73L), repository.requestedDetailIds) assertTrue(states.last() is AfternoteDetailUiState.Error) @@ -93,8 +93,8 @@ class AfternoteDetailViewModelTest { val states = recordStates(viewModel) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(73L), repository.requestedDetailIds) @@ -118,8 +118,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(73L, 73L), repository.requestedDetailIds) assertEquals("Facebook", states.last().serviceNameOrNull()) @@ -146,8 +146,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository, errorReporter = reporter) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 상세가 에러 화면으로 대체되지 않는다. assertEquals("Instagram", states.last().serviceNameOrNull()) @@ -202,8 +202,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository, myProfileRepository = myProfileRepository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 갱신이 만든 새 Success 도 이름을 들고 있어야 제목이 «…에 대한 기록» 으로 되돌아가지 않는다. assertEquals("Threads", states.last().serviceNameOrNull()) @@ -307,9 +307,9 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.deleteAfternote() - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.Delete) + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME val last = states.last() as AfternoteDetailUiState.Success assertEquals("Threads", states.last().serviceNameOrNull()) @@ -338,7 +338,7 @@ class AfternoteDetailViewModelTest { assertTrue(states.last() is AfternoteDetailUiState.Error) - viewModel.retry() + viewModel.onIntent(AfternoteDetailIntent.Retry) // 응답이 오기 전 — 사용자가 누른 동작이므로 기다림을 표시한다(자동 갱신과 갈리는 지점). assertTrue(states.last() is AfternoteDetailUiState.Loading) @@ -365,7 +365,7 @@ class AfternoteDetailViewModelTest { } 2 -> { - viewModelRef.retry() + viewModelRef.onIntent(AfternoteDetailIntent.Retry) Result.success(detail(serviceName = "Stale")) } @@ -379,8 +379,8 @@ class AfternoteDetailViewModelTest { viewModelRef = viewModel val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME — 이 로드가 재시도에 잘린다 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME — 이 로드가 재시도에 잘린다 assertEquals("Retry", states.last().serviceNameOrNull()) } @@ -402,7 +402,7 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.deleteAfternote() + viewModel.onIntent(AfternoteDetailIntent.Delete) assertTrue(states.last() is AfternoteDetailUiState.Error) assertEquals(emptyList(), deletedIds) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt index 02474ebfd4..78b32fcb82 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt @@ -54,8 +54,7 @@ class AddSongSearchProgressTest { AfternoteTheme { AddSongScreen( uiState = uiState, - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt new file mode 100644 index 0000000000..524938f605 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt @@ -0,0 +1,64 @@ +package com.afternote.feature.afternote.presentation.editor.memorial + +import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository +import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class AddSongViewModelTest { + @After + fun resetDispatcher() = Dispatchers.resetMain() + + @Test + fun `검색 Intent는 debounce한 최신 검색어만 조회하고 실패를 소비한 뒤 다시 표시한다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val queries = mutableListOf() + val repository = + MusicSearchRepository { query -> + queries += query + Result.failure(IOException("offline")) + } + val viewModel = AddSongViewModel(repository, NoopAuthorErrorReporter) + viewModel.onIntent(AddSongIntent.Search("old")) + runCurrent() + advanceTimeBy(299) + viewModel.onIntent(AddSongIntent.Search(" latest ")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(listOf("latest"), queries) + assertEquals(" latest ", viewModel.uiState.value.searchQuery) + assertEquals(R.string.afternote_editor_search_failed_generic, viewModel.uiState.value.errorRes) + + viewModel.onIntent(AddSongIntent.ConsumeError) + assertNull(viewModel.uiState.value.errorRes) + viewModel.onIntent(AddSongIntent.Search("latest")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(2, queries.size) + assertEquals(R.string.afternote_editor_search_failed_generic, viewModel.uiState.value.errorRes) + + viewModel.onIntent(AddSongIntent.Search(" ")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(2, queries.size) + assertEquals(false, viewModel.uiState.value.isLoading) + assertNull(viewModel.uiState.value.errorRes) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt index 2ff4bbe927..3fc5cea18a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt @@ -118,7 +118,7 @@ class SelectReceiverViewModelTest { assertTrue(viewModel.uiState.value.loadFailed) repository.onGetReceivers = null - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() val state = viewModel.uiState.value @@ -131,10 +131,10 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) } @@ -143,8 +143,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(2L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(2L, 1L), viewModel.uiState.value.selectedReceiverIds) } @@ -154,9 +154,9 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(2L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(2L), viewModel.uiState.value.selectedReceiverIds) } @@ -174,8 +174,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) - viewModel.toggleReceiverSelection(1L) - viewModel.applyPreselection(listOf(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) } @@ -185,8 +185,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) } @@ -200,7 +200,7 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.applyPreselection(listOf(1L, 99L)) + viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L, 99L))) gate.complete( listOf( @@ -227,10 +227,10 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(2L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) repository.receiverState.value = listOf(Receiver(1L, "김혜성", "아들", "auth-1")) - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) @@ -244,8 +244,8 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.toggleReceiverSelection(1L) - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) @@ -267,7 +267,7 @@ class SelectReceiverViewModelTest { ) val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) runCurrent() - beforeDeath.toggleReceiverSelection(2L) + beforeDeath.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) runCurrent() @@ -288,13 +288,13 @@ class SelectReceiverViewModelTest { ), ) val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) - beforeDeath.applyPreselection(listOf(1L)) + beforeDeath.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) runCurrent() - beforeDeath.toggleReceiverSelection(1L) + beforeDeath.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) // 재생성 뒤 Route 의 LaunchedEffect 가 같은 폼 수신자로 다시 부른다. val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) - restored.applyPreselection(listOf(1L)) + restored.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) runCurrent() assertEquals(emptyList(), restored.uiState.value.selectedReceiverIds) @@ -314,7 +314,7 @@ class SelectReceiverViewModelTest { ), ) val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) - if (formReceiverIds.isNotEmpty()) viewModel.applyPreselection(formReceiverIds) + if (formReceiverIds.isNotEmpty()) viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(formReceiverIds)) dispatcher.scheduler.runCurrent() return viewModel } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt index ebe8668066..55424216af 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt @@ -41,7 +41,7 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("목록 조회 실패")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) assertEquals(listOf("list_load"), reporter.recordedStages) assertEquals(listOf(IOException::class.java.name), reporter.recordedErrorTypes) @@ -52,7 +52,7 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - repeat(5) { viewModel.onListLoadFailed(IOException("목록 조회 실패")) } + repeat(5) { viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) } assertEquals(1, reporter.recordedStages.size) } @@ -62,8 +62,8 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("연결 실패")) - viewModel.onListLoadFailed(SocketTimeoutException("응답 없음")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("연결 실패"))) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(SocketTimeoutException("응답 없음"))) assertEquals( listOf(IOException::class.java.name, SocketTimeoutException::class.java.name), @@ -76,9 +76,9 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("목록 조회 실패")) - viewModel.onListLoadSucceeded() - viewModel.onListLoadFailed(IOException("목록 조회 실패")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) + viewModel.onIntent(AfternoteHomeIntent.ListLoadSucceeded) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) assertEquals(listOf("list_load", "list_load"), reporter.recordedStages) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt index cd0766bfdb..ad98f7a14a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt @@ -63,12 +63,12 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨) — 재조회가 걸리면 안 된다. - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) assertEquals(listOf(42L), repository.requestedDetailIds) assertTrue(states.last() is ReceivedAfternoteDetailUiState.Error) // 복구는 사용자의 수동 재시도로만 일어난다. - viewModel.retry() + viewModel.onIntent(ReceivedAfternoteDetailIntent.Retry) assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals("Instagram", states.last().serviceNameOrNull()) } @@ -88,8 +88,8 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(42L), repository.requestedDetailIds) @@ -113,8 +113,8 @@ class ReceivedAfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals("Facebook", states.last().serviceNameOrNull()) @@ -141,8 +141,8 @@ class ReceivedAfternoteDetailViewModelTest { val viewModel = viewModel(repository, errorReporter = reporter) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 상세가 에러 화면으로 대체되지 않는다. assertEquals("Instagram", states.last().serviceNameOrNull()) @@ -175,7 +175,7 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 로드가 매달린 사이 사용자가 «다시 시도하기» — 옛 Job 은 잘리고 새 로드가 화면을 채운다. - viewModel.retry() + viewModel.onIntent(ReceivedAfternoteDetailIntent.Retry) assertEquals("Fresh", states.last().serviceNameOrNull()) // 잘린 옛 로드가 이제야 값을 들고 돌아온다. diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt index e508ac4f14..514ad56f7f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt @@ -74,7 +74,7 @@ class ReceiverMemorialPlaylistViewModelTest { assertEquals(IOException::class.java.name, errorReporter.reportedErrors.single().message) assertTrue(errorReporter.reportedErrors.none { it.message?.contains("sensitive") == true }) - viewModel.retry() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.Retry) assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals(2, errorReporter.reportedErrors.size) @@ -98,7 +98,7 @@ class ReceiverMemorialPlaylistViewModelTest { val viewModel = viewModel(afternoteId = 42L, repository = repository) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨). - viewModel.refreshOnReturn() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) assertEquals(listOf(42L), repository.requestedDetailIds) assertTrue(viewModel.uiState.value is ReceiverMemorialPlaylistUiState.Error) @@ -118,8 +118,8 @@ class ReceiverMemorialPlaylistViewModelTest { val viewModel = viewModel(afternoteId = 42L, repository = repository) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(42L), repository.requestedDetailIds) @@ -147,8 +147,8 @@ class ReceiverMemorialPlaylistViewModelTest { viewModel.uiState.collect { states += it } } - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(42L, 42L), repository.requestedDetailIds) val refreshed = states.last() as ReceiverMemorialPlaylistUiState.Success @@ -178,8 +178,8 @@ class ReceiverMemorialPlaylistViewModelTest { errorReporter = errorReporter, ) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 목록이 에러 화면으로 대체되지 않는다. val state = viewModel.uiState.value as ReceiverMemorialPlaylistUiState.Success diff --git a/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt b/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt new file mode 100644 index 0000000000..469e214f50 --- /dev/null +++ b/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt @@ -0,0 +1,52 @@ +package com.afternote.feature.afternote.presentation.testing + +import androidx.compose.runtime.Composable +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry +import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute +import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.receiver.domain.repository.ReceiverRepository + +/** 실제 목록 VM과 화면을 연결한다. 테스트는 저장소 응답과 화면의 사용자 액션으로 동작을 검증한다. */ +public class AfternoteHomeScreenFixture( + repository: AfternoteRepository, + errorReporter: ErrorReporter, +) { + private val viewModel = AfternoteHomeViewModel(repository, errorReporter) + + @Composable + public fun Content( + navigateToDetail: (Long) -> Unit, + navigateToAdd: (AfternoteType) -> Unit, + onSettingClick: () -> Unit, + ) { + AfternoteHomeEntry(navigateToDetail, navigateToAdd, onSettingClick, viewModel) + } +} + +/** 수신 상세의 실제 조회·재시도·Lifecycle·렌더 경계를 앱 기기 테스트에서도 그대로 사용한다. */ +public class ReceivedAfternoteDetailScreenFixture( + afternoteId: Long, + receiverRepository: ReceiverRepository, + errorReporter: ErrorReporter, +) { + private val viewModel = + ReceivedAfternoteDetailViewModel( + route = ReceivedAfternoteRoute.DetailRoute(afternoteId), + receiverRepository = receiverRepository, + errorReporter = errorReporter, + ) + + @Composable + public fun Content( + onNavigateBack: () -> Unit, + onNavigateToFullList: () -> Unit, + onNavigateToPlaylist: (Long) -> Unit, + ) { + ReceivedAfternoteDetailRoute(onNavigateBack, onNavigateToFullList, onNavigateToPlaylist, viewModel) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index f0c26766b4..8bc386655a 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -363,19 +363,6 @@ class MviContractKonsistTest { */ val STATE_HOLDER = Regex("""\b(MutableStateFlow|MutableSharedFlow|Channel)\s*[(<]""") - /** #1804 가 뺀다. */ - private val ISSUE_1804_AFTERNOTE = - setOf( - "com.afternote.feature.afternote.presentation.AfternoteHostViewModel", - "com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel", - "com.afternote.feature.afternote.presentation.editor.memorial.AddSongViewModel", - "com.afternote.feature.afternote.presentation.editor.receiver.SelectReceiverViewModel", - "com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel", - "com.afternote.feature.afternote.presentation.receiver.afternotelist.ReceiverAfternoteHomeViewModel", - "com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel", - "com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel", - ) - /** #1808 이 뺀다. */ private val ISSUE_1808_HOME = setOf( @@ -471,7 +458,6 @@ class MviContractKonsistTest { ISSUE_1802_ONBOARDING + PENDING_ARRIVAL_ONBOARDING + ISSUE_1803_RECEIVER + - ISSUE_1804_AFTERNOTE + ISSUE_1805_SETTING + ISSUE_1806_TIMELETTER + ISSUE_1807_MINDRECORD + diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index dbae9883db..17a4888fc9 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -296,15 +296,6 @@ class ProductionVisibilityKonsistTest { "core/ui/src/main/kotlin/com/afternote/core/ui/topbar/HomeTopBar.kt::property PROFILE_ICON_TEST_TAG [implicit-public]", ) - /** - * 남은 항목은 MVI 전환(#1804)이 다시 열 화면 seam 이라 #1817 이 그 뒤에 판정한다. - * #1673 의 나머지(mapper·test tag·Saver·목록 매퍼)는 해소돼 목록에서 빠졌다. - */ - val ISSUE_1673_AFTERNOTE = - setOf( - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt::function DeleteInProgressOverlay [internal]", - ) - /** * 남은 항목은 MVI 전환(#1807)이 다시 열 Content seam 이라 #1818 이 그 뒤에 판정한다. * #1674 의 나머지(매퍼 2건·화면 helper 3건·요약 helper 2건·주간 날짜 복원)는 해소돼 @@ -356,7 +347,6 @@ class ProductionVisibilityKonsistTest { val LEGACY_TEST_DRIVEN_VISIBILITY = ISSUE_1671_PLATFORM + ISSUE_1672_CORE + - ISSUE_1673_AFTERNOTE + ISSUE_1674_MINDRECORD + ISSUE_1675_ONBOARDING + ISSUE_1676_RECEIVER + @@ -384,9 +374,6 @@ class ProductionVisibilityKonsistTest { "core/ui/src/main/kotlin/com/afternote/core/ui/button/" + "AfternoteRadioGroup.kt::function AfternoteRadioGroup [implicit-public]" to "core:ui README에 문서화된 공용 컴포넌트", - "core/ui/src/main/kotlin/com/afternote/core/ui/mvi/" + - "ObserveSignal.kt::function ObserveSignal [implicit-public]" to - "MVI 일회성 신호 소비 관용구(#1800) — 화면 전환 이슈가 소비처를 만든다", "core/ui/src/main/kotlin/com/afternote/core/ui/popup/" + "AfternoteActionMenu.kt::class ActionMenuItem [implicit-public]" to "AfternoteActionMenu 파라미터와 editDeleteActionMenuItems 반환값을 외부 feature가 타입 추론 소비", diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt index a7d1feec94..3fc7986eb7 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt @@ -1,6 +1,8 @@ package com.afternote.konsist import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -19,7 +21,7 @@ import org.junit.Test * * ### 규칙 * main 소스셋의 어느 클래스가 `refreshOnReturn` 을 선언하면, **자기 파일이 아닌** main 소스셋 - * 파일 중 `Lifecycle.Event.ON_RESUME` 과 `refreshOnReturn(` 을 함께 갖고 그 클래스 이름을 + * 파일 중 `Lifecycle.Event.ON_RESUME`과 직접 갱신 호출 또는 해당 ViewModel의 RefreshOnReturn Intent를 갖고 클래스 이름을 * 참조하는 파일이 최소 하나 있어야 한다. * * 모듈을 가로지르는 결선(홈 탭은 `feature:home:presentation` 의 ViewModel 을 app 모듈 @@ -35,7 +37,7 @@ class RefreshOnReturnWiringKonsistTest { @Test fun `refreshOnReturn 을 선언한 ViewModel 은 ON_RESUME 결선을 갖는다`() { val mainFiles = mainSourceFiles() - val wiringSites = mainFiles.filter { ON_RESUME in it.text && REFRESH_CALL in it.text } + val wiringSites = mainFiles.filter { ON_RESUME in it.text } val unwired = mainFiles @@ -46,7 +48,7 @@ class RefreshOnReturnWiringKonsistTest { .map { declaration -> file to declaration.name } }.filter { (declaringFile, className) -> wiringSites.none { site -> - site.projectPath != declaringFile.projectPath && site.references(className) + site.projectPath != declaringFile.projectPath && site.references(className) && hasRefreshCall(site.text, className) } }.map { (declaringFile, className) -> "${declaringFile.normalizedProjectPath()} — $className" } @@ -64,6 +66,23 @@ class RefreshOnReturnWiringKonsistTest { } } + @Test + fun `MVI 전환 뒤에도 해당 ViewModel의 resume Intent 결선을 요구한다`() { + assertTrue(hasRefreshCall("viewModel.onIntent(ExampleIntent.RefreshOnReturn)", "ExampleViewModel")) + assertTrue(hasRefreshCall("viewModel.refreshOnReturn()", "ExampleViewModel")) + assertFalse(hasRefreshCall("viewModel.onIntent(OtherIntent.RefreshOnReturn)", "ExampleViewModel")) + assertFalse(hasRefreshCall("viewModel.onIntent(ExampleIntent.Retry)", "ExampleViewModel")) + } + + private fun hasRefreshCall( + text: String, + className: String, + ): Boolean { + val intentName = className.removeSuffix("ViewModel") + "Intent" + val intentCall = Regex("""\bonIntent\s*\(\s*${Regex.escape(intentName)}\.RefreshOnReturn\s*\)""") + return REFRESH_CALL in text || intentCall.containsMatchIn(text) + } + private fun mainSourceFiles(): List = AfternoteKonsistScope.files.filter { "/src/main/" in it.normalizedProjectPath() } From 8dae413296b4a25186d29feb133a8b4b280c578d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 20:43:21 +0900 Subject: [PATCH 009/116] test(afternote): restore process-death fixture with current snapshot key (#1732) --- .../editor/AfternoteEditorProcessDeathPrefillTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index cd8bbdf54e..838f4f90c2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -216,7 +216,7 @@ class AfternoteEditorProcessDeathPrefillTest { buildMap { put("initialType", AfternoteType.GALLERY_AND_FILES) put("itemId", EDIT_ID) - put("editor_form_snapshot_v4", snapshot) + put("editor_form_snapshot_v5", snapshot) // 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식. if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) }, From 1a4f91b7dd46e790dd12c8e995e0ed719fe19bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 20:43:21 +0900 Subject: [PATCH 010/116] test(afternote): round-trip photos through current snapshot key (#1902) --- .../editor/AfternoteEditorMemorialMediaRemoveTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index 61ac63cd04..580cff63e5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -198,13 +198,13 @@ class AfternoteEditorMemorialMediaRemoveTest { val original = viewModel(handle) original.applyPrefill(memorialPrefill(photoUrl = persisted)) if (selection != null) original.setMemorialPhoto(selection) - val encoded = requireNotNull(handle.get("editor_form_snapshot_v4")) + val encoded = requireNotNull(handle.get("editor_form_snapshot_v5")) val fields = Json.parseToJsonElement(encoded).jsonObject assertEquals(persisted?.let(::JsonPrimitive) ?: JsonNull, fields["memorialPhotoUrl"]) assertEquals(selection?.let(::JsonPrimitive) ?: JsonNull, fields["pickedMemorialPhotoUri"]) // 같은 진입 경로의 새 SavedStateHandle로 실제 직렬화된 스냅샷만 복사한다. - val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v4", encoded) }) + val restored = viewModel(memorialSavedStateHandle().apply { set("editor_form_snapshot_v5", encoded) }) val photo = requireNotNull(restored.currentForm().memorialPhoto) assertEquals(original.currentForm(), restored.currentForm()) assertEquals(selection ?: persisted, photo.displayed) From 119d926d0e3e7d83fef6e88824313e73676b6281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 20:46:59 +0900 Subject: [PATCH 011/116] fix(afternote): distinguish media upload size failures (#1868) --- .../editor/AfternoteEditorRouteHelpers.kt | 4 + .../editor/AfternoteEditorViewModel.kt | 9 +- .../editor/state/AfternoteSaveState.kt | 3 + .../src/main/res/values/strings.xml | 1 + .../AfternoteEditorMediaSizeExceededTest.kt | 196 ++++++++++++++++++ .../AfternoteEditorSnackbarActionTest.kt | 2 + 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMediaSizeExceededTest.kt diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt index 91b441a69b..517276782f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt @@ -34,6 +34,10 @@ internal fun AfternoteEditorError.messageResId(): Int = R.string.afternote_editor_prefill_not_ready } + AfternoteEditorError.MediaSizeExceeded -> { + R.string.afternote_editor_media_size_exceeded + } + is AfternoteEditorError.Upload -> { when (target) { AfternoteEditorError.Upload.Target.THUMBNAIL -> R.string.afternote_editor_thumbnail_upload_failed diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 86369121bd..e176aa0166 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.error.FileUploadFailure import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.AfternoteType @@ -960,9 +961,13 @@ private fun Throwable.toAfternoteEditorError(): AfternoteEditorError = when (this) { is AfternoteFailure -> { when (this) { - // 미디어 해석 실패는 사용자가 입력을 고쳐 푸는 검증 실패가 아니라 업로드 장애다. + // 크기 초과는 같은 파일의 재전송으로 풀리지 않으므로 일반 업로드 실패와 구분한다. is AfternoteFailure.MediaSave -> { - AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA) + if (cause is FileUploadFailure.FileSizeExceeded) { + AfternoteEditorError.MediaSizeExceeded + } else { + AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA) + } } is AfternoteFailure.NetworkUnavailable -> { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt index e155b25d5f..42252bce9b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/AfternoteSaveState.kt @@ -71,6 +71,9 @@ sealed interface AfternoteEditorError { */ data object PrefillNotReady : AfternoteEditorError + /** 사진·영상 파일이 서버의 업로드 한도를 초과했다. 같은 파일의 재시도를 안내하지 않는다. */ + data object MediaSizeExceeded : AfternoteEditorError + data class Upload( val target: Target, ) : AfternoteEditorError { diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 682f225139..0f3ccba560 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -161,6 +161,7 @@ 말하지 않는다 — 곧 도착하므로 기다리면 저장할 수 있다. --> 기존 내용을 불러오는 중입니다.\n잠시 후 다시 시도해 주세요. 다시 불러오기 + 파일 크기가 업로드 허용 한도를 초과했습니다. 영상 미리보기를 올리지 못했습니다. 영상에서 미리보기를 만들지 못했습니다. 다시 시도 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMediaSizeExceededTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMediaSizeExceededTest.kt new file mode 100644 index 0000000000..66b382e47e --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMediaSizeExceededTest.kt @@ -0,0 +1,196 @@ +package com.afternote.feature.afternote.presentation.editor + +import com.afternote.core.domain.error.FileUploadFailure +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.repository.author.MediaInput +import com.afternote.feature.afternote.domain.repository.author.MediaKind +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase +import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository +import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload +import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialPhoto +import com.afternote.feature.afternote.presentation.editor.state.EditableMemorialVideo +import com.afternote.feature.afternote.presentation.editor.state.EditorFormState +import com.afternote.feature.afternote.presentation.editorFlowRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +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.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +/** + * 업로드 크기 초과(서버 code 1803)를 일반 저장 실패와 가른다 (#1868). + * + * 크기 초과는 재시도로 풀리지 않는 유일한 업로드 실패다 — 같은 파일을 다시 보내면 서버가 같은 + * 코드로 다시 거절한다. 「저장에 실패했습니다」 로 뭉쳐 두면 사용자는 파일을 바꿔야 풀린다는 것도, + * 기다렸다 다시 눌러 봐야 소용없다는 것도 알 수 없다. + * + * 진입은 공개 [AfternoteEditorIntent.Save] 하나이고, 실패는 실제 [ResolveMemorialMediaForSaveUseCase] + * 를 통과해 온다 — 매핑만 직접 불러 확인하면 UseCase 가 cause 를 다르게 감싸도 초록으로 남는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorMediaSizeExceededTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `영상이 한도를 넘으면 저장을 보내지 않고 크기 초과로 알린다`() { + assertResolveFailure( + failingKind = MediaKind.VIDEO, + uploadFailure = fileSizeExceeded(), + expected = AfternoteEditorError.MediaSizeExceeded, + ) + } + + @Test + fun `사진이 한도를 넘으면 저장을 보내지 않고 크기 초과로 알린다`() { + assertResolveFailure( + failingKind = MediaKind.PHOTO, + uploadFailure = fileSizeExceeded(), + expected = AfternoteEditorError.MediaSizeExceeded, + ) + } + + /** + * 갈래를 늘리면서 기존 안내를 뭉개지 않았는지 본다 — 사유를 확인하지 못한 업로드 실패는 + * data 계층이 치환하지 않고 원본 그대로 흘려보내므로, 여기서도 종전 일반 오류로 남아야 한다. + */ + @Test + fun `사유를 모르는 업로드 실패는 기존 저장 미디어 오류로 남는다`() { + assertResolveFailure( + failingKind = MediaKind.VIDEO, + uploadFailure = IOException("connection reset"), + expected = AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA), + ) + } + + @Test + fun `크기 초과는 일반 저장 실패와 다른 문구로 나가고 다시 시도를 권하지 않는다`() { + assertEquals( + R.string.afternote_editor_media_size_exceeded, + AfternoteEditorError.MediaSizeExceeded.messageResId(), + ) + assertNotEquals( + "크기 초과가 일반 저장 실패와 같은 문구로 나가면 파일을 바꿔야 한다는 사실이 사라진다", + AfternoteEditorError.Upload(AfternoteEditorError.Upload.Target.SAVE_MEDIA).messageResId(), + AfternoteEditorError.MediaSizeExceeded.messageResId(), + ) + assertFalse( + "같은 파일을 다시 보내면 서버가 같은 코드로 거절한다 — 재시도 액션을 붙이면 안 된다", + AfternoteEditorError.MediaSizeExceeded.offersMemorialThumbnailRetry(), + ) + } + + /** + * @param failingKind 업로드가 거절당하는 매체. 영상이 먼저 해석되므로 사진 갈래는 영상이 + * 통과한 뒤에야 도달한다 ([ResolveMemorialMediaForSaveUseCase]). + */ + private fun assertResolveFailure( + failingKind: MediaKind, + uploadFailure: Throwable, + expected: AfternoteEditorError, + ) = runTest(dispatcher) { + // strict fake — 저장 요청이 한 줄이라도 나가면 그 자리에서 터진다. + val repository = FakeAfternoteRepository.strict() + val viewModel = viewModel(repository, failingKind, uploadFailure) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialPhoto(PHOTO_URI)) + viewModel.onIntent(AfternoteEditorIntent.SetMemorialVideo(VIDEO_URI)) + + val pickedMedia = + viewModel.uiState.value.form + .memorialMediaForSave() + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = RegisterAfternotePayload(serviceName = "추억 노트", date = "2026.09.20"), + selectedReceiverIds = emptyList(), + memorialMedia = pickedMedia, + ), + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(expected, state.error) + assertFalse("업로드에서 끊겼는데 저장 중 표시가 남으면 등록 버튼이 잠긴 채로 굳는다", state.isSaving) + assertNull(state.savedId) + // 업로드에서 끊겼으니 저장 요청 자체가 나가지 않는다 — 나갔다면 payload 가 기록된다. + assertTrue("미디어를 못 올렸는데 저장이 나가면 미디어 없는 기록이 확정된다", repository.memorialPayloads.isEmpty()) + // 고른 파일은 그대로 둔다 — 한도를 넘긴 쪽만 바꿔 다시 저장할 수 있어야 한다. + assertEquals(PHOTO_URI, state.form.displayMemorialPhotoUri()) + assertEquals(VIDEO_URI, state.form.displayedMemorialVideo?.url) + } + + private fun viewModel( + repository: FakeAfternoteRepository, + failingKind: MediaKind, + uploadFailure: Throwable, + ): AfternoteEditorViewModel { + val savedStateHandle = afternoteEditorSavedStateHandle(initialType = AfternoteType.MEMORIAL) + return AfternoteEditorViewModel( + route = savedStateHandle.editorFlowRoute(), + savedStateHandle = savedStateHandle, + userReceiverRepository = afternoteAuthorUserReceiverRepository(), + afternoteRepository = repository, + memorialThumbnailUploadRepository = + MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, + resolveMemorialMediaForSave = + ResolveMemorialMediaForSaveUseCase( + MemorialMediaUploadRepository { input, kind -> + when { + kind == failingKind -> Result.failure(uploadFailure) + input is MediaInput.Local -> Result.success("https://cdn.test/${kind.name.lowercase()}") + else -> Result.success(null) + } + }, + ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), + errorReporter = NoopAuthorErrorReporter, + ) + } + + /** 서버 봉투의 code 1803 을 data 계층이 옮겨 놓은 모양 — presentation 은 이 타입만 보고 가른다. */ + private fun fileSizeExceeded(): FileUploadFailure.FileSizeExceeded = + FileUploadFailure.FileSizeExceeded(IllegalStateException("code=1803 FILE_SIZE_EXCEEDED")) + + private fun EditorFormState.memorialMediaForSave(): SaveAfternoteMemorialMedia = + SaveAfternoteMemorialMedia( + memorialVideo = memorialVideo ?: EditableMemorialVideo.empty(), + memorialPhoto = memorialPhoto ?: EditableMemorialPhoto.empty(), + ) + + private companion object { + const val PHOTO_URI = "content://photos/portrait" + const val VIDEO_URI = "content://videos/farewell" + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSnackbarActionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSnackbarActionTest.kt index eda7c4876f..000ac7e485 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSnackbarActionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorSnackbarActionTest.kt @@ -24,6 +24,8 @@ class AfternoteEditorSnackbarActionTest { fun `저장 실패와 그 밖의 오류에는 썸네일 재시도가 붙지 않는다`() { listOf( AfternoteEditorError.Upload(Target.SAVE_MEDIA), + // 크기 초과는 재시도로 풀리지 않는 유일한 업로드 실패다 — 다시 보내면 서버가 같은 코드로 거절한다 (#1868). + AfternoteEditorError.MediaSizeExceeded, AfternoteEditorError.Network, AfternoteEditorError.Server, AfternoteEditorError.ReceiverSelectionUnavailable, From 4f140515e9382da56fdffbc060a5c47f76a68fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 20:53:51 +0900 Subject: [PATCH 012/116] fix(build): upgrade Kotlin to security patch 2.4.20 (#986) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d925501d9c..a81c75e09a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,9 +13,9 @@ robolectric = "4.15.1" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -kotlin = "2.4.10" +kotlin = "2.4.20" composeBom = "2026.06.01" -jetbrainsKotlinJvm = "2.4.10" +jetbrainsKotlinJvm = "2.4.20" hilt = "2.60.1" hiltNavigation = "1.4.0" ksp = "2.3.10" From 73118e79109b1dfd29971ff23285a4308148ae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 22:06:00 +0900 Subject: [PATCH 013/116] refactor(setting): extract receiver save policies (#1691) --- feature/setting/domain/build.gradle.kts | 6 + .../domain/UpdateReceiverInfoUseCase.kt | 68 +++++++ ...pdateTimeLetterDeliveryConditionUseCase.kt | 58 ++++++ .../setting/domain/ReceiverSavePolicyTest.kt | 181 ++++++++++++++++++ .../delivery/DeliveryConditionScreen.kt | 2 +- .../delivery/DeliveryConditionViewModel.kt | 55 +----- .../receiver/ReceiverEditScreen.kt | 2 +- .../receiver/ReceiverEditViewModel.kt | 64 +++---- .../SettingAccountSecurityTest.kt | 2 + .../presentation/SettingCompletionTest.kt | 5 + 10 files changed, 363 insertions(+), 80 deletions(-) create mode 100644 feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt create mode 100644 feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt create mode 100644 feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt diff --git a/feature/setting/domain/build.gradle.kts b/feature/setting/domain/build.gradle.kts index 39b64eebda..9b6f1c8941 100644 --- a/feature/setting/domain/build.gradle.kts +++ b/feature/setting/domain/build.gradle.kts @@ -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)) } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt new file mode 100644 index 0000000000..3444b9aa16 --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateReceiverInfoUseCase.kt @@ -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 + } + } + } diff --git a/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt new file mode 100644 index 0000000000..83c12da89d --- /dev/null +++ b/feature/setting/domain/src/main/java/com/afternote/feature/setting/domain/UpdateTimeLetterDeliveryConditionUseCase.kt @@ -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, + 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) + } + } diff --git a/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt new file mode 100644 index 0000000000..860f6cd60e --- /dev/null +++ b/feature/setting/domain/src/test/kotlin/com/afternote/feature/setting/domain/ReceiverSavePolicyTest.kt @@ -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, + ) +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index f2a6df6269..e4916a34e0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -36,7 +36,7 @@ import com.afternote.feature.setting.presentation.delivery.component.RadioGroup import com.afternote.feature.setting.presentation.delivery.component.RadioGroupItem @Composable -fun DeliveryConditionScreen( +internal fun DeliveryConditionScreen( onBack: () -> Unit, onSaveSuccess: () -> Unit, onLastGreetingEditClick: () -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 3297bbd07e..96ebe4c01b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -6,11 +6,10 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable 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.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -23,11 +22,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class DeliveryConditionViewModel +internal class DeliveryConditionViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val receiverRepository: UserReceiverRepository, + private val updateTimeLetterDeliveryCondition: UpdateTimeLetterDeliveryConditionUseCase, ) : ViewModel() { private val receiverId = savedStateHandle.toRoute().receiverId @@ -75,41 +75,15 @@ class DeliveryConditionViewModel val state = _uiState.value if (!state.isInitialized || state.isSaving) return - val hasTimeLetterCondition = - state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } - val updatedConditions = - state.conditions - .map { condition -> - if (condition.contentType == DeliveryContentType.TIME_LETTER) { - condition.copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } else { - condition - } - }.let { conditions -> - if (hasTimeLetterCondition) { - conditions - } else { - conditions + - defaultCondition(DeliveryContentType.TIME_LETTER).copy( - conditionType = state.conditionType, - inactivityPeriod = - state.inactivityPeriod.takeIf { - state.conditionType == DeliveryConditionType.INACTIVITY - }, - ) - } - } - viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } runCatchingCancellable { - receiverRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) + updateTimeLetterDeliveryCondition( + receiverId = receiverId, + conditions = state.conditions, + conditionType = state.conditionType, + inactivityPeriod = state.inactivityPeriod, + ) }.onSuccess { response -> _uiState.update { it.copy(isSaving = false, conditions = response.conditions) } _saveSuccess.send(Unit) @@ -118,15 +92,4 @@ class DeliveryConditionViewModel } } } - - private fun defaultCondition(contentType: DeliveryContentType) = - DeliveryConditionItem( - contentType = contentType, - conditionType = DeliveryConditionType.INACTIVITY, - inactivityPeriod = InactivityPeriod.ONE_YEAR, - state = ConditionState.ACTIVE, - fulfilled = false, - gracePeriodStartedAt = null, - fulfilledAt = null, - ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt index c271a3a217..a1eae8bfbf 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditScreen.kt @@ -9,7 +9,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun ReceiverEditScreen( +internal fun ReceiverEditScreen( onBackClick: () -> Unit, onEditSuccess: () -> Unit, modifier: Modifier = Modifier, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 7d96bde7b8..bee820eda9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -7,6 +7,8 @@ import androidx.navigation.toRoute import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.ui.UiText +import com.afternote.feature.setting.domain.UpdateReceiverInfoResult +import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,11 +21,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ReceiverEditViewModel +internal class ReceiverEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val receiverRepository: UserReceiverRepository, + private val updateReceiverInfo: UpdateReceiverInfoUseCase, ) : ViewModel() { private val receiverId = savedStateHandle.toRoute().receiverId @@ -76,40 +79,37 @@ class ReceiverEditViewModel _uiState.update { it.copy(isSaving = true, errorMessage = null) } viewModelScope.launch { - val receiverUpdateResult = - runCatchingCancellable { - receiverRepository.updateReceiver( - receiverId = receiverId, - name = name, - phone = phone.normalizeReceiverPhone(), - relation = relation, - email = email.trim(), - ) - } - receiverUpdateResult.exceptionOrNull()?.let { error -> - _uiState.update { - it.copy( - isSaving = false, - errorMessage = error.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), - ) - } - return@launch - } - - runCatchingCancellable { - receiverRepository.updateReceiverMessage( + val result = + updateReceiverInfo( receiverId = receiverId, + name = name, + phone = phone.normalizeReceiverPhone(), + relation = relation, + email = email.trim(), message = message, ) - }.onSuccess { - _uiState.update { it.copy(isSaving = false) } - _events.send(ReceiverEditEvent.EditSuccess) - }.onFailure { - _uiState.update { - it.copy( - isSaving = false, - errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), - ) + when (result) { + is UpdateReceiverInfoResult.BasicInfoFailed -> { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.cause.toReceiverFailureMessage(R.string.setting_receiver_edit_failed), + ) + } + } + + UpdateReceiverInfoResult.MessageFailedAfterBasicInfoUpdated -> { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = UiText.Resource(R.string.setting_receiver_message_update_partial_failed), + ) + } + } + + UpdateReceiverInfoResult.Success -> { + _uiState.update { it.copy(isSaving = false) } + _events.send(ReceiverEditEvent.EditSuccess) } } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 65f796b111..195e19c594 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -23,6 +23,7 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent @@ -206,6 +207,7 @@ class SettingAccountSecurityTest { DeliveryConditionViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index e2069f57ff..18ebb91867 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -35,6 +35,8 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel @@ -380,6 +382,7 @@ class SettingCompletionTest { ReceiverEditViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.receiver == COMPLETION_DEFAULT_RECEIVER_DETAIL @@ -473,6 +476,7 @@ class SettingCompletionTest { ReceiverEditViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateReceiverInfo = UpdateReceiverInfoUseCase(repository), ) composeRule.setContent { @@ -531,6 +535,7 @@ class SettingCompletionTest { DeliveryConditionViewModel( savedStateHandle = SavedStateHandle(mapOf("receiverId" to RECEIVER_ID)), receiverRepository = repository, + updateTimeLetterDeliveryCondition = UpdateTimeLetterDeliveryConditionUseCase(repository), ) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isInitialized From 9d8b7fb8692157eb40ae2a1f4b4051d8d810c8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 20 Sep 2026 22:36:47 +0900 Subject: [PATCH 014/116] fix(setting): propagate repository cancellation (#1501) --- .../account/ConnectedAccountsScreen.kt | 2 +- .../account/ConnectedAccountsViewModel.kt | 9 +- .../presentation/home/SettingViewModel.kt | 5 +- .../home/WithdrawConfirmScreen.kt | 2 +- .../notification/NotificationSettingScreen.kt | 2 +- .../notification/PushNotificationScreen.kt | 2 +- .../notification/PushNotificationViewModel.kt | 4 +- .../presentation/profile/ProfileEditScreen.kt | 2 +- .../profile/ProfileEditViewModel.kt | 7 +- .../SettingCoroutineCancellationTest.kt | 368 ++++++++++++++++++ 10 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 6e6d6295f4..93774de3f9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -22,7 +22,7 @@ import com.afternote.feature.setting.presentation.account.social.requestKakaoAcc import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult @Composable -fun ConnectedAccountsScreen( +internal fun ConnectedAccountsScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ConnectedAccountsViewModel = hiltViewModel(), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 9bb3992114..88d50b1263 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.account import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.model.user.UserConnectedAccount import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R @@ -15,7 +16,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ConnectedAccountsViewModel +internal class ConnectedAccountsViewModel @Inject constructor( private val accountRepository: SettingAccountRepository, @@ -32,7 +33,7 @@ class ConnectedAccountsViewModel private fun loadConnectedAccounts() { viewModelScope.launch { - runCatching { accountRepository.getConnectedAccounts() } + runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } }.onFailure { @@ -61,7 +62,7 @@ class ConnectedAccountsViewModel accessToken: String, ) { viewModelScope.launch { - runCatching { accountRepository.linkConnectedAccount(provider, accessToken) } + runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } } @@ -69,7 +70,7 @@ class ConnectedAccountsViewModel private fun unlink(provider: String) { viewModelScope.launch { - runCatching { accountRepository.unlinkConnectedAccount(provider) } + runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index d051696f53..e28c058206 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.feature.setting.domain.SettingAccountRepository @@ -42,7 +43,7 @@ sealed interface WithdrawUiState { * 로컬 토큰은 결과 무관하게 정리하도록 위임돼 있다. 호출처는 토큰 정리 여부를 신경 쓸 필요 없음. */ @HiltViewModel -class SettingViewModel +internal class SettingViewModel @Inject constructor( private val authRepository: AuthRepository, @@ -63,7 +64,7 @@ class SettingViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { myProfileRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { profile -> _uiState.value = SettingUiState.Success( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt index 73560e2fb2..ea165e8839 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt @@ -10,7 +10,7 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.feature.setting.presentation.R @Composable -fun WithdrawConfirmScreen( +internal fun WithdrawConfirmScreen( uiState: SettingUiState, onBackClick: () -> Unit, onWithdrawSuccess: () -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index b5a271b264..011c180321 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -47,7 +47,7 @@ import com.afternote.feature.setting.presentation.notification.component.DeviceA import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem @Composable -fun NotificationSettingScreen( +internal fun NotificationSettingScreen( onBack: () -> Unit, onPushNotificationClick: () -> Unit, viewModel: PushNotificationViewModel = hiltViewModel(), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index 2dcb24789a..ea13176186 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -13,7 +13,7 @@ import com.afternote.feature.setting.presentation.notification.component.DeviceA import com.afternote.feature.setting.presentation.notification.component.PushToggleSection @Composable -fun PushNotificationScreen( +internal fun PushNotificationScreen( onBack: () -> Unit, viewModel: PushNotificationViewModel = hiltViewModel(), ) { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index 4e928b830b..f55695787b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel -class PushNotificationViewModel +internal class PushNotificationViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -84,7 +84,7 @@ class PushNotificationViewModel private fun loadMarketingConsents() { viewModelScope.launch { Log.d(TAG, "loadMarketingConsents: start") - runCatching { notificationRepository.getMyMarketingConsents() } + runCatchingCancellable { notificationRepository.getMyMarketingConsents() } .onSuccess { consent -> Log.d(TAG, "loadMarketingConsents: success=$consent") _uiState.update { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 5bb9aff051..956d90c94e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -36,7 +36,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable -fun ProfileEditScreen( +internal fun ProfileEditScreen( onBackClick: () -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index 6fabdd2988..9d11017016 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -2,6 +2,7 @@ package com.afternote.feature.setting.presentation.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -13,7 +14,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ProfileEditViewModel +internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, @@ -30,7 +31,7 @@ class ProfileEditViewModel private fun loadProfile() { viewModelScope.launch { - runCatching { myProfileRepository.getMyProfile() } + runCatchingCancellable { myProfileRepository.getMyProfile() } .onSuccess { user -> _uiState.value = ProfileEditUiState.Success( @@ -51,7 +52,7 @@ class ProfileEditViewModel val current = _uiState.value as? ProfileEditUiState.Success ?: return _uiState.update { current.copy(isUpdating = true) } viewModelScope.launch { - runCatching { + runCatchingCancellable { myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt new file mode 100644 index 0000000000..52c92558ac --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -0,0 +1,368 @@ +package com.afternote.feature.setting.presentation + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.model.user.User +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingUiState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationEvent +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.profile.ProfileEditEvent +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +/** + * 설정 화면 ViewModel 의 suspend 저장소 호출이 코루틴 취소를 실패로 둔갑시키지 않는지 검증한다 (#1501). + * + * 취소는 화면 이탈로 [ViewModel.onCleared] 가 불릴 때 실제로 일어나므로, 여기서는 진짜 + * [ViewModelStore] 에 담았다가 [ViewModelStore.clear] 로 끊는다. 저장소 fake 는 응답하지 않고 + * 매달려 있어, 취소가 대기 중인 호출까지 닿았는지를 [PendingRepositoryCall.isCancelled] 로 본다. + * 예외를 직접 던지는 검사에 더해, 실제 ViewModel 종료가 진행 중 요청을 취소하는 경로까지 검증한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingCoroutineCancellationTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ShadowLog.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `연결 계정 조회 중 화면을 떠나면 오류 문구로 덮지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onGetConnectedAccounts = { pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `계정 연결 중 화면을 떠나면 연결 실패 문구를 띄우지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onLinkConnectedAccount = { _, _ -> pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + + viewModel.link(provider = "google", accessToken = "google-token") + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `계정 연결 해제 중 화면을 떠나면 해제 실패 문구를 띄우지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingAccountRepository().apply { + onUnlinkConnectedAccount = { pending.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + + viewModel.onToggle(provider = "naver", enabled = false) + runCurrent() + assertTrue(pending.isStarted) + val pendingState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(pendingState, viewModel.uiState.value) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun `프로필 편집 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeMyProfileRepository().apply { + onGetMyProfile = { pending.await() } + } + val viewModel = ProfileEditViewModel(repository) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(ProfileEditUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `프로필 수정 중 화면을 떠나면 갱신 표시를 풀거나 실패 이벤트를 보내지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeMyProfileRepository(profile = PROFILE).apply { + onUpdateMyProfile = { _, _, _ -> pending.await() } + } + val viewModel = ProfileEditViewModel(repository) + val store = storeHolding(viewModel) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals( + ProfileEditUiState.Success( + name = PROFILE.name, + phone = PROFILE.phone.orEmpty(), + email = PROFILE.email, + isUpdating = true, + ), + viewModel.uiState.value, + ) + assertTrue(events.isEmpty()) + } + + @Test + fun `설정 홈 프로필 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val viewModel = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { pending.await() } + }, + accountRepository = FakeSettingAccountRepository.strict(), + ) + val store = storeHolding(viewModel) + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(SettingUiState.Loading, viewModel.uiState.value) + } + + @Test + fun `마케팅 동의 조회 중 화면을 떠나면 상태 진단 어느 것도 남기지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = true, mindRecord = false, afterNote = true) } + onGetMyMarketingConsents = { pending.await() } + } + val reporter = RecordingErrorReporter() + val viewModel = pushViewModel(repository, reporter) + val store = storeHolding(viewModel) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + // 푸시 설정 조회는 먼저 정상 종료해 안정된 상태를 만든다. 매달린 것은 마케팅 조회뿐이다. + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(pending.isStarted) + val stableState = viewModel.uiState.value + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertEquals(stableState, viewModel.uiState.value) + assertTrue(events.isEmpty()) + assertEquals(0, reporter.recordedStages.size) + assertEquals(emptyList(), pushFailureLogs()) + } + + @Test + fun `마케팅 동의 조회는 성공하면 상태에 반영하고 평범한 실패는 로그만 남긴다`() = + runTest(dispatcher) { + val successReporter = RecordingErrorReporter() + val successViewModel = + pushViewModel( + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { UserMarketingConsent(sms = false, email = true, push = true) } + }, + successReporter, + ) + runCurrent() + + assertFalse(successViewModel.uiState.value.isSmsChecked) + assertTrue(successViewModel.uiState.value.isEmailChecked) + assertTrue(successViewModel.uiState.value.isPushChecked) + assertEquals(emptyList(), pushFailureLogs()) + + ShadowLog.clear() + val failureReporter = RecordingErrorReporter() + val failureViewModel = + pushViewModel( + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { error("marketing consents unavailable") } + }, + failureReporter, + ) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { failureViewModel.events.collect { events += it } } + runCurrent() + + // 평범한 실패는 진단 로그 한 줄로 끝난다. 화면 값은 기본값 그대로이고 안내도 띄우지 않는다. + assertEquals(listOf("loadMarketingConsents: failed"), pushFailureLogs()) + assertTrue(failureViewModel.uiState.value.isSmsChecked) + assertTrue(failureViewModel.uiState.value.isEmailChecked) + assertFalse(failureViewModel.uiState.value.isPushChecked) + assertTrue(events.isEmpty()) + assertEquals(0, failureReporter.recordedStages.size) + } + + @Test + fun `Exception 이 아닌 Throwable 실패도 평소대로 오류 상태로 매핑한다`() = + runTest(dispatcher) { + val viewModel = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { throw NonExceptionFailure() } + }, + accountRepository = FakeSettingAccountRepository.strict(), + ) + runCurrent() + + assertEquals(SettingUiState.Error("프로필을 불러올 수 없습니다."), viewModel.uiState.value) + } + + private fun pushViewModel( + repository: FakeSettingNotificationRepository, + reporter: ErrorReporter, + ) = PushNotificationViewModel( + context = ApplicationProvider.getApplicationContext(), + notificationRepository = repository, + errorReporter = reporter, + ) + + /** 푸시 ViewModel 이 남긴 실패 진단 로그의 메시지 목록. 성공 경로의 `Log.d` 는 세지 않는다. */ + private fun pushFailureLogs(): List = + ShadowLog + .getLogsForTag(PUSH_TAG) + .filter { it.type >= Log.ERROR } + .map { it.msg } + + private fun storeHolding(viewModel: ViewModel): ViewModelStore = ViewModelStore().apply { put(STORE_KEY, viewModel) } + + private class RecordingErrorReporter : ErrorReporter { + val recordedStages = mutableListOf() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + recordedStages += attributes["stage"] + } + } + + /** `Exception` 을 상속하지 않는 실패. `runCatching` 계열이 `Throwable` 까지 잡는다는 계약을 고정한다. */ + private class NonExceptionFailure : Throwable("profile backend down") + + private companion object { + const val STORE_KEY = "setting-cancellation" + const val PUSH_TAG = "PushNotificationVM" + val PROFILE = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = null) + } +} + +/** + * 응답하지 않는 저장소 호출. 취소가 이 자리까지 닿았는지를 [isCancelled] 로 드러낸다. + * + * 취소 콜백(`invokeOnCancellation`)은 코루틴이 취소될 때만 호출되므로, 대기 중인 suspend 호출이 + * 실제로 끊겼다는 증거가 된다. + */ +private class PendingRepositoryCall { + @Volatile + var isStarted = false + private set + + @Volatile + var isCancelled = false + private set + + suspend fun await(): T = + suspendCancellableCoroutine { continuation -> + isStarted = true + continuation.invokeOnCancellation { isCancelled = true } + } +} From 57b3ad305a443140e3d279676d455fa480970c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 23 Sep 2026 16:32:01 +0900 Subject: [PATCH 015/116] =?UTF-8?q?refactor(setting):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=95=8C=EB=A6=BC=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20ErrorReporter=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20(#963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뉴스레터·마음의 기록·애프터노트 토글의 공통 저장 경로에서 실패를 recordFailure 로 남긴다. 속성은 stage=push_setting_update 와 push_setting 토글 이름뿐이다. 조회 실패는 Logcat 에 두고, 롤백·실패 안내·재시도 동작은 바꾸지 않는다. --- .../notification/PushNotificationViewModel.kt | 18 ++ .../PushNotificationSaveReportingTest.kt | 218 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index f55695787b..6243823ec5 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -75,6 +75,7 @@ internal class PushNotificationViewModel ) } }.onFailure { e -> + // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) _uiState.update { it.copy(isLoading = false) } } @@ -179,6 +180,13 @@ internal class PushNotificationViewModel }.onSuccess { _uiState.update { it.withUpdating(update.setting, updating = false) } }.onFailure { failure -> + errorReporter.recordFailure( + failure, + mapOf( + KEY_STAGE to STAGE_PUSH_SETTING_UPDATE, + KEY_PUSH_SETTING to update.setting.reportingName(), + ), + ) failedUpdate = update _uiState.update { it @@ -193,6 +201,8 @@ internal class PushNotificationViewModel companion object { private const val TAG = "PushNotificationVM" private const val KEY_STAGE = "stage" + private const val KEY_PUSH_SETTING = "push_setting" + private const val STAGE_PUSH_SETTING_UPDATE = "push_setting_update" private const val STAGE_SMS_CONSENT = "sms_consent_update" private const val STAGE_EMAIL_CONSENT = "email_consent_update" private const val STAGE_PUSH_CONSENT = "push_consent_update" @@ -205,6 +215,14 @@ private enum class PushSetting { AFTERNOTE, } +/** 진단 속성 값. 토글 종류만 담는 고정 문자열이라 사용자 정보가 섞이지 않는다. */ +private fun PushSetting.reportingName(): String = + when (this) { + PushSetting.NEWSLETTER -> "newsletter" + PushSetting.MIND_RECORD -> "mind_record" + PushSetting.AFTERNOTE -> "afternote" + } + private data class PushSettingUpdate( val setting: PushSetting, val on: Boolean, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt new file mode 100644 index 0000000000..df72f27a59 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt @@ -0,0 +1,218 @@ +package com.afternote.feature.setting.presentation.notification + +import androidx.lifecycle.ViewModelStore +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 서비스 알림 토글 저장 실패가 개발자 진단으로 남는지 검증한다 (#963). + * + * 저장 실패만 [ErrorReporter] 로 올리고 조회 실패·성공·취소는 올리지 않는다. 기록 속성은 단계와 + * 토글 이름뿐이고, [ErrorReporter.recordFailure] 가 덧붙이는 예외 타입 외에는 아무것도 싣지 않는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PushNotificationSaveReportingTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `세 토글의 저장 실패는 토글마다 고정 속성으로 한 번씩 기록하고 롤백과 실패 안내는 그대로다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, _, _ -> error("server") } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + viewModel.onNewsletterToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + + viewModel.onMindRecordToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureDismiss() + + viewModel.onAfternoteToggle(false) + runCurrent() + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + assertEquals( + listOf(saveFailureOf("newsletter"), saveFailureOf("mind_record"), saveFailureOf("afternote")), + reporter.records.map { it.saveContext() }, + ) + reporter.records.forEach { attributes -> + assertTrue("허용되지 않은 속성: ${attributes.keys}", ALLOWED_KEYS.containsAll(attributes.keys)) + assertEquals(IllegalStateException::class.java.name, attributes["error_type"]) + } + } + + @Test + fun `저장에 성공하면 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val viewModel = viewModel(FakeSettingNotificationRepository(pushSetting = ALL_ON), reporter) + runCurrent() + + viewModel.onNewsletterToggle(false) + viewModel.onMindRecordToggle(false) + viewModel.onAfternoteToggle(false) + runCurrent() + + assertFalse(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isMindRecordOn) + assertFalse(viewModel.uiState.value.isAfternoteOn) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `저장 중 화면을 떠나 취소되면 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + var cancelled = false + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, _, _ -> + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancelled = true } + } + } + } + val viewModel = viewModel(repository, reporter) + val store = ViewModelStore().apply { put(STORE_KEY, viewModel) } + runCurrent() + + viewModel.onMindRecordToggle(false) + runCurrent() + assertEquals(1, repository.pushUpdateCalls.size) + + store.clear() + runCurrent() + + assertTrue(cancelled) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `서비스 알림과 마케팅 동의 조회 실패는 진단을 남기지 않는다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { error("push settings unavailable") } + onGetMyMarketingConsents = { error("marketing consents unavailable") } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + assertEquals(1, repository.getMyPushSettingsCalls) + assertEquals(1, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(reporter.records.isEmpty()) + } + + @Test + fun `재시도가 다시 실패하면 실제 실패마다 한 번씩 기록한다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() + var remainingFailures = 2 + val repository = + FakeSettingNotificationRepository(pushSetting = ALL_ON).apply { + onUpdateMyPushSettings = { _, mindRecord, _ -> + if (remainingFailures > 0) { + remainingFailures-- + error("server") + } + ALL_ON.copy(mindRecord = mindRecord ?: ALL_ON.mindRecord) + } + } + val viewModel = viewModel(repository, reporter) + runCurrent() + + viewModel.onMindRecordToggle(false) + runCurrent() + viewModel.onSaveFailureRetry() + runCurrent() + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + viewModel.onSaveFailureRetry() + runCurrent() + + assertFalse(viewModel.uiState.value.isMindRecordOn) + assertEquals(3, repository.pushUpdateCalls.size) + assertEquals( + List(2) { saveFailureOf("mind_record") }, + reporter.records.map { it.saveContext() }, + ) + } + + private fun viewModel( + repository: FakeSettingNotificationRepository, + reporter: ErrorReporter, + ) = PushNotificationViewModel( + context = ApplicationProvider.getApplicationContext(), + notificationRepository = repository, + errorReporter = reporter, + ) + + /** [ErrorReporter.recordFailure] 가 정책을 적용한 뒤 넘긴 속성을 그대로 모은다. */ + private class RecordingReporter : ErrorReporter { + val records = mutableListOf>() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + records += attributes + } + } + + private fun Map.saveContext(): Map = filterKeys { it == KEY_STAGE || it == KEY_PUSH_SETTING } + + private fun saveFailureOf(pushSetting: String): Map = + mapOf(KEY_STAGE to "push_setting_update", KEY_PUSH_SETTING to pushSetting) + + private companion object { + const val STORE_KEY = "push-save-reporting" + const val KEY_STAGE = "stage" + const val KEY_PUSH_SETTING = "push_setting" + val ALLOWED_KEYS = setOf(KEY_STAGE, KEY_PUSH_SETTING, "error_type", "error_cause_type") + val ALL_ON = UserPushSetting(timeLetter = true, mindRecord = true, afterNote = true) + } +} From d0b688dbe94a41fb621975c76a4cade5d8bb3099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 23 Sep 2026 16:42:10 +0900 Subject: [PATCH 016/116] =?UTF-8?q?fix(setting):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=88=98=EC=A0=95=EC=97=90=EC=84=9C=20=EA=B0=A4?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=82=AC=EC=A7=84=EC=9D=84=20=EA=B3=A8?= =?UTF-8?q?=EB=9D=BC=20=EC=A0=80=EC=9E=A5=ED=95=9C=EB=8B=A4=20(#1438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배지를 core ProfileImagePicker 로 바꿔 갤러리(PickVisualMedia ImageOnly)를 연다. 고른 사진은 저장할 때 profiles 로 올리고 받은 fileKey 로 PATCH users/me 를 보낸다. 업로드가 실패하면 수정 요청을 보내지 않는다. 서버 profileImageUrl 을 아바타에 표시하고, 선택 취소는 기존 사진을 지우지 않는다. 조회 중이거나 저장 중에 돌아온 선택은 받을 수 있을 때까지 보관한다. --- .../presentation/profile/ProfileEditScreen.kt | 58 ++++- .../profile/ProfileEditUiState.kt | 13 +- .../profile/ProfileEditViewModel.kt | 30 ++- .../component/ProfilePhotoWithAddBadge.kt | 14 +- .../SettingAccountSecurityTest.kt | 5 +- .../presentation/SettingCompletionTest.kt | 3 +- .../SettingCoroutineCancellationTest.kt | 35 ++- .../profile/ProfileEditPhotoPickerTest.kt | 205 ++++++++++++++++++ .../profile/ProfileEditViewModelTest.kt | 199 +++++++++++++++++ 9 files changed, 544 insertions(+), 18 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 956d90c94e..28a2c0f4b9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -1,5 +1,9 @@ package com.afternote.feature.setting.presentation.profile +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +23,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,12 +35,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.ProfileImagePicker import com.afternote.core.ui.button.AfternoteButton import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge @Composable internal fun ProfileEditScreen( @@ -44,6 +50,12 @@ internal fun ProfileEditScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnBackClick by rememberUpdatedState(onBackClick) + // when 분기 밖에서 만든다. 분기 안에 두면 상태가 바뀔 때 런처 등록과 보관 중인 결과가 함께 사라진다. + val onPickProfileImage = + rememberProfileImagePicker( + canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false, + onPhotoPicked = viewModel::selectProfileImage, + ) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -80,6 +92,7 @@ internal fun ProfileEditScreen( is ProfileEditUiState.Success -> { ProfileEditForm( state = state, + onPickImageClick = onPickProfileImage, onUpdateClick = viewModel::updateProfile, onWithdrawGuideClick = onWithdrawGuideClick, modifier = Modifier.padding(innerPadding), @@ -104,6 +117,7 @@ internal fun ProfileEditScreen( @Composable private fun ProfileEditForm( state: ProfileEditUiState.Success, + onPickImageClick: () -> Unit, onUpdateClick: (name: String, phone: String) -> Unit, onWithdrawGuideClick: () -> Unit, modifier: Modifier = Modifier, @@ -123,7 +137,10 @@ private fun ProfileEditForm( .padding(top = 50.dp), contentAlignment = Alignment.Center, ) { - ProfilePhotoWithAddBadge() + ProfileImagePicker( + onPickClick = onPickImageClick, + displayImageUri = state.displayImageUri, + ) } } item { @@ -214,3 +231,38 @@ private fun ProfileEditForm( } } } + +/** + * 갤러리 사진 선택기를 등록하고, 누르면 띄우는 함수를 돌려준다. + * + * 온보딩 프로필 화면과 같이 갤러리 전용이다(`PickVisualMedia.ImageOnly`). 취소 결과(`null`)는 선택 + * 변경이 아니라서 기존 사진을 그대로 둔다(온보딩 #1113·#1115 와 같은 처리). + * + * 돌아온 사진은 [canAcceptPhoto] 가 참일 때까지 [rememberSaveable] 에 보관했다가 넘긴다. 갤러리에 + * 다녀오는 사이 프로세스가 회수되면 결과가 새 ViewModel 의 프로필 조회보다 먼저 도착하고, 저장 중에 + * 돌아온 결과는 ViewModel 이 받지 않기 때문이다. 바로 넘기면 두 경우 모두 고른 사진이 사라진다. + */ +@Composable +private fun rememberProfileImagePicker( + canAcceptPhoto: Boolean, + onPhotoPicked: (String) -> Unit, +): () -> Unit { + val queuedPhoto = rememberSaveable { mutableStateOf(null) } + val currentOnPhotoPicked by rememberUpdatedState(onPhotoPicked) + + LaunchedEffect(queuedPhoto.value, canAcceptPhoto) { + val uri = queuedPhoto.value + if (uri != null && canAcceptPhoto) { + queuedPhoto.value = null + currentOnPhotoPicked(uri) + } + } + + val launcher = + rememberLauncherForActivityResult(PickVisualMedia()) { uri: Uri? -> + uri?.let { queuedPhoto.value = it.toString() } + } + return remember(launcher) { + { launcher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt index bc4fbf71d1..429d39edef 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt @@ -3,12 +3,23 @@ package com.afternote.feature.setting.presentation.profile sealed interface ProfileEditUiState { data object Loading : ProfileEditUiState + /** + * @param profileImageUrl 서버가 내려준 표시용 프로필 사진 주소. 아직 사진이 없으면 널이다. + * @param selectedImageUri 이 화면에서 고르기만 하고 아직 저장하지 않은 사진의 content URI. + * 저장이 성공하기 전까지 서버 사진은 건드리지 않는다. 선택 취소나 저장 실패가 기존 사진을 + * 지우지 않는 자리다(#1438). + */ data class Success( val name: String, val phone: String, val email: String, + val profileImageUrl: String?, + val selectedImageUri: String? = null, val isUpdating: Boolean = false, - ) : ProfileEditUiState + ) : ProfileEditUiState { + /** 아바타에 그릴 사진. 저장 전 선택이 서버 사진보다 앞선다. */ + val displayImageUri: String? get() = selectedImageUri ?: profileImageUrl + } data object Error : ProfileEditUiState } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index 9d11017016..daa7f72a02 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.PhotoUploadRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -13,11 +14,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * `POST /files/presigned-url` 의 `directory` 값. `PATCH users/me` 가 받은 키를 `profiles` 로 + * 승격하는 서버 계약과 짝이다. + */ +private const val PROFILE_UPLOAD_DIRECTORY = "profiles" + @HiltViewModel internal class ProfileEditViewModel @Inject constructor( private val myProfileRepository: MyProfileRepository, + private val photoUploadRepository: PhotoUploadRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileEditUiState.Loading) val uiState = _uiState.asStateFlow() @@ -38,6 +46,7 @@ internal class ProfileEditViewModel name = user.name, phone = user.phone.orEmpty(), email = user.email, + profileImageUrl = user.profileImageUrl, ) }.onFailure { _uiState.value = ProfileEditUiState.Error @@ -45,6 +54,19 @@ internal class ProfileEditViewModel } } + /** 갤러리에서 고른 사진을 저장 전 선택으로 둔다. 서버 사진은 저장이 성공할 때 바뀐다. */ + fun selectProfileImage(uri: String) { + _uiState.update { state -> + // 저장 중 선택은 받지 않는다. 업로드는 저장을 누른 시점의 사진으로 진행되므로, + // 받아들이면 화면에 보이는 사진과 실제로 올라가는 사진이 갈린다. + if (state is ProfileEditUiState.Success && !state.isUpdating) { + state.copy(selectedImageUri = uri) + } else { + state + } + } + } + fun updateProfile( name: String, phone: String, @@ -53,10 +75,16 @@ internal class ProfileEditViewModel _uiState.update { current.copy(isUpdating = true) } viewModelScope.launch { runCatchingCancellable { + // 업로드가 실패하면 여기서 멈춘다. 수정 요청이 나가지 않으므로 이름·연락처·사진 모두 그대로다. + val uploadedKey = + current.selectedImageUri?.let { uri -> + photoUploadRepository.upload(uri, PROFILE_UPLOAD_DIRECTORY).getOrThrow().fileKey + } myProfileRepository.updateMyProfile( name = name.takeIf { it.isNotBlank() }, phone = phone.takeIf { it.isNotBlank() }, - profileImageUrl = null, + // 서버는 표시용 URL 이 아니라 업로드 키를 받아 승격한다. 널이면 기존 사진을 유지한다. + profileImageUrl = uploadedKey, ) }.onSuccess { _events.send(ProfileEditEvent.UpdateSuccess) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt index 6636c642be..b7082c635b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/ProfilePhotoWithAddBadge.kt @@ -13,16 +13,14 @@ import androidx.compose.ui.unit.dp import com.afternote.feature.setting.presentation.R /** - * 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 아직 picker 가 붙어 있지 않다. + * 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 picker 가 붙어 있지 않다. * - * 프로필 이미지 크기 134dp 의 단일 출처다(#1782). setting 의 프로필 편집·수신인 등록 두 화면이 - * 같은 블록을 복제하고 있어 여기로 모았다. + * 남은 소비처는 수신인 등록 화면 하나다. 프로필 편집 화면은 사진 선택을 붙이면서(#1438) core:ui 의 + * [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 옮겼다. 시안의 프로필 노드가 + * core 자산(`core_ui_ic_profile_placeholder`, 48dp 배지)과 같다는 #2003 의 판정을 따랐다. * - * core:ui 의 [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 흡수하지 않은 - * 이유는 자산과 배지 기하가 달라서다 — 이쪽은 `ic_default_profile`(60dp 뷰포트) 과 - * 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓰고, core 쪽은 - * `core_ui_ic_profile_placeholder` 와 48dp `PlusBadgeButton` 을 쓴다. 바꾸면 픽셀이 움직인다. - * 수렴은 시안 대조가 선행돼야 하므로 #2003 으로 분리했다. + * 이쪽은 `ic_default_profile`(60dp 뷰포트)과 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓴다. + * 수신인 등록 화면의 수렴은 #2003 에 남아 있다. */ @Composable fun ProfilePhotoWithAddBadge(modifier: Modifier = Modifier) { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 195e19c594..f333896654 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository.DeliveryUpdateCall import com.afternote.core.model.delivery.ConditionState @@ -72,7 +73,7 @@ class SettingAccountSecurityTest { settingContractProfileRepository().apply { onGetMyProfile = { throw IllegalStateException("profile unavailable") } } - val loadFailureViewModel = ProfileEditViewModel(loadFailureRepository) + val loadFailureViewModel = ProfileEditViewModel(loadFailureRepository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { loadFailureViewModel.uiState.value == ProfileEditUiState.Error @@ -92,7 +93,7 @@ class SettingAccountSecurityTest { assertTrue(loadFailureRepository.profileUpdateCalls.isEmpty()) val updateFailureRepository = settingContractProfileRepository() - val updateFailureViewModel = ProfileEditViewModel(updateFailureRepository) + val updateFailureViewModel = ProfileEditViewModel(updateFailureRepository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { updateFailureViewModel.uiState.value is ProfileEditUiState.Success } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 18ebb91867..db45efec81 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -19,6 +19,7 @@ import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.domain.testing.FakeUserReceiverRepository import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem @@ -93,7 +94,7 @@ class SettingCompletionTest { val scenario = CompletionUserScenario() val repository = scenario.profileRepository val updateGate = scenario.enqueueProfileUpdate() - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index 52c92558ac..ffbe2bb681 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ApplicationProvider import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.model.user.User import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting @@ -146,7 +147,7 @@ class SettingCoroutineCancellationTest { FakeMyProfileRepository().apply { onGetMyProfile = { pending.await() } } - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) val store = storeHolding(viewModel) runCurrent() assertTrue(pending.isStarted) @@ -166,7 +167,7 @@ class SettingCoroutineCancellationTest { FakeMyProfileRepository(profile = PROFILE).apply { onUpdateMyProfile = { _, _, _ -> pending.await() } } - val viewModel = ProfileEditViewModel(repository) + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) val store = storeHolding(viewModel) val events = mutableListOf() backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } @@ -185,6 +186,7 @@ class SettingCoroutineCancellationTest { name = PROFILE.name, phone = PROFILE.phone.orEmpty(), email = PROFILE.email, + profileImageUrl = PROFILE.profileImageUrl, isUpdating = true, ), viewModel.uiState.value, @@ -192,6 +194,34 @@ class SettingCoroutineCancellationTest { assertTrue(events.isEmpty()) } + @Test + fun `프로필 사진 업로드 중 화면을 떠나면 수정 요청도 실패 이벤트도 보내지 않는다`() = + runTest(dispatcher) { + val pending = PendingRepositoryCall() + val profileRepository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { PROFILE } + } + val uploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> pending.await() }) + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + val store = storeHolding(viewModel) + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(pending.isStarted) + + store.clear() + runCurrent() + + assertTrue(pending.isCancelled) + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertTrue(events.isEmpty()) + } + @Test fun `설정 홈 프로필 조회 중 화면을 떠나면 Error 상태로 바꾸지 않는다`() = runTest(dispatcher) { @@ -341,6 +371,7 @@ class SettingCoroutineCancellationTest { private companion object { const val STORE_KEY = "setting-cancellation" const val PUSH_TAG = "PushNotificationVM" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" val PROFILE = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = null) } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt new file mode 100644 index 0000000000..ac9901a1b2 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhotoPickerTest.kt @@ -0,0 +1,205 @@ +package com.afternote.feature.setting.presentation.profile + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import com.afternote.core.ui.R as CoreUiR + +/** + * 프로필 편집 화면의 사진 배지 → 갤러리 선택 결과 처리 (#1438). + * + * 피커 자체는 띄우지 않는다. [LocalActivityResultRegistryOwner] 를 결과를 정해 둔 레지스트리로 갈아 + * 끼우고 화면의 공개 계약(배지 탭 → 피커 요청 → ViewModel 의 선택)만 본다. 온보딩의 + * `OnboardingProfilePickerResultTest` 와 같은 관용구다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditPhotoPickerTest { + @get:Rule + val composeRule = createComposeRule() + + private val pickBadge = + ApplicationProvider + .getApplicationContext() + .getString(CoreUiR.string.core_ui_content_description_profile_edit) + + @Test + fun `배지는 갤러리 이미지 선택만 요청하고 취소는 기존 사진을 지우지 않는다`() { + val registry = GalleryResultRegistry(result = null) + val viewModel = loadedViewModel() + setContent(registry) { viewModel } + awaitSuccess(viewModel) + + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { + assertEquals(listOf(PickVisualMedia.ImageOnly), registry.requestedMediaTypes) + assertNull(viewModel.success().selectedImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().displayImageUri) + } + } + + @Test + fun `갤러리에서 고른 사진은 저장 전 선택으로 아바타에 실린다`() { + val registry = GalleryResultRegistry(result = Uri.parse(PICKED_PHOTO)) + val viewModel = loadedViewModel() + setContent(registry) { viewModel } + awaitSuccess(viewModel) + + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().selectedImageUri == PICKED_PHOTO } + composeRule.runOnIdle { assertEquals(PICKED_PHOTO, viewModel.success().displayImageUri) } + } + + @Test + fun `프로필 조회가 끝나기 전에 돌아온 사진은 보관했다가 조회가 끝나면 넘긴다`() { + // 갤러리에 다녀오는 사이 프로세스가 회수된 경우를 흉내 낸다. 화면은 그대로 복원되고 ViewModel 만 + // 새로 생겨 조회 중일 때 피커 결과가 먼저 도착한다. + val registry = GalleryResultRegistry(result = null, deferResult = true) + val firstViewModel = loadedViewModel() + val pendingProfile = CompletableDeferred() + val restoredViewModel = + ProfileEditViewModel( + FakeMyProfileRepository.strict().apply { onGetMyProfile = { pendingProfile.await() } }, + FakePhotoUploadRepository.strict(), + ) + var current by mutableStateOf(firstViewModel) + setContent(registry) { current } + awaitSuccess(firstViewModel) + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { current = restoredViewModel } + composeRule.waitForIdle() + composeRule.runOnIdle { registry.deliver(Uri.parse(PICKED_PHOTO)) } + composeRule.waitForIdle() + composeRule.runOnIdle { assertEquals(ProfileEditUiState.Loading, restoredViewModel.uiState.value) } + + pendingProfile.complete(SAVED) + + composeRule.waitUntil(TIMEOUT_MILLIS) { + (restoredViewModel.uiState.value as? ProfileEditUiState.Success)?.selectedImageUri == PICKED_PHOTO + } + composeRule.runOnIdle { assertNull(firstViewModel.success().selectedImageUri) } + } + + @Test + fun `저장 중에 돌아온 사진은 보관했다가 저장이 실패한 뒤에 넘긴다`() { + val registry = GalleryResultRegistry(result = null, deferResult = true) + val pendingUpdate = CompletableDeferred() + val viewModel = + ProfileEditViewModel( + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> pendingUpdate.await() } + }, + FakePhotoUploadRepository.strict(), + ) + setContent(registry) { viewModel } + awaitSuccess(viewModel) + composeRule.onNodeWithContentDescription(pickBadge).performClick() + + composeRule.runOnIdle { viewModel.updateProfile(name = "새 이름", phone = "01011112222") } + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().isUpdating } + composeRule.runOnIdle { registry.deliver(Uri.parse(PICKED_PHOTO)) } + composeRule.waitForIdle() + composeRule.runOnIdle { assertNull(viewModel.success().selectedImageUri) } + + pendingUpdate.completeExceptionally(IOException("offline")) + + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.success().selectedImageUri == PICKED_PHOTO } + } + + private fun setContent( + registry: GalleryResultRegistry, + viewModel: () -> ProfileEditViewModel, + ) { + val registryOwner = + object : ActivityResultRegistryOwner { + override val activityResultRegistry = registry + } + composeRule.setContent { + AfternoteTheme { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides registryOwner) { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel()) + } + } + } + } + + private fun awaitSuccess(viewModel: ProfileEditViewModel) { + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } + composeRule.waitForIdle() + } + + private fun loadedViewModel() = + ProfileEditViewModel( + FakeMyProfileRepository.strict().apply { onGetMyProfile = { SAVED } }, + FakePhotoUploadRepository.strict(), + ) + + private fun ProfileEditViewModel.success(): ProfileEditUiState.Success = uiState.value as ProfileEditUiState.Success + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + const val SERVER_IMAGE_URL = "https://cdn.test/profiles/saved.jpg" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" + val SAVED = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = SERVER_IMAGE_URL) + } +} + +/** 갤러리 피커 요청을 기록하고, 정해 둔 결과를 즉시 또는 [deliver] 때 돌려주는 레지스트리. */ +private class GalleryResultRegistry( + private val result: Uri?, + private val deferResult: Boolean = false, +) : ActivityResultRegistry() { + val requestedMediaTypes = mutableListOf() + private var pendingRequestCode: Int? = null + + fun deliver(uri: Uri?) { + val requestCode = checkNotNull(pendingRequestCode) { "피커 요청이 없다" } + pendingRequestCode = null + dispatchResult(requestCode, uri) + } + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + requestedMediaTypes += (input as PickVisualMediaRequest).mediaType + if (deferResult) { + pendingRequestCode = requestCode + } else { + @Suppress("UNCHECKED_CAST") + dispatchResult(requestCode, result as O) + } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt new file mode 100644 index 0000000000..cc4f6c3c73 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt @@ -0,0 +1,199 @@ +package com.afternote.feature.setting.presentation.profile + +import com.afternote.core.domain.model.UploadedFile +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +/** + * 프로필 사진 변경의 저장 계약 (#1438). + * + * 고른 사진은 `profiles` 로 올린 뒤 받은 파일 키로 `PATCH users/me` 를 보낸다. 업로드가 실패하면 + * 수정 요청을 보내지 않고, 선택하지 않았으면 사진 칸을 널로 보내 서버 사진을 그대로 둔다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `서버 프로필 사진을 싣고 고른 사진은 저장 전까지 그 위에만 보인다`() = + runTest(dispatcher) { + val viewModel = ProfileEditViewModel(FakeMyProfileRepository(profile = SAVED), FakePhotoUploadRepository.strict()) + runCurrent() + + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + assertEquals(SERVER_IMAGE_URL, viewModel.success().displayImageUri) + + viewModel.selectProfileImage(PICKED_PHOTO) + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(PICKED_PHOTO, viewModel.success().displayImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + } + + @Test + fun `고른 사진은 profiles 로 올린 뒤 받은 파일 키로 수정 요청을 보낸다`() = + runTest(dispatcher) { + val order = mutableListOf() + val uploadRepository = + FakePhotoUploadRepository( + onUpload = { _, _ -> + order += "upload" + Result.success(UploadedFile(fileUrl = UPLOADED_URL, fileKey = UPLOADED_KEY)) + }, + ) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> + order += "patch" + SAVED + } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + + assertEquals(listOf("upload", "patch"), order) + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = "01011112222", profileImageUrl = UPLOADED_KEY)), + profileRepository.profileUpdateCalls, + ) + assertEquals(listOf(ProfileEditEvent.UpdateSuccess), events) + } + + @Test + fun `사진을 고르지 않으면 업로드 없이 사진 칸을 비워 보내 기존 사진을 유지한다`() = + runTest(dispatcher) { + val profileRepository = FakeMyProfileRepository(profile = SAVED) + val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict()) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.updateProfile(name = "새 이름", phone = "") + runCurrent() + + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = null, profileImageUrl = null)), + profileRepository.profileUpdateCalls, + ) + assertEquals(listOf(ProfileEditEvent.UpdateSuccess), events) + } + + @Test + fun `업로드가 실패하면 수정 요청을 보내지 않고 고른 사진을 그대로 둔다`() = + runTest(dispatcher) { + val uploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> Result.failure(IOException("upload failed")) }) + val profileRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { SAVED } } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + + assertEquals(1, uploadRepository.uploads.size) + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(SERVER_IMAGE_URL, viewModel.success().profileImageUrl) + assertFalse(viewModel.success().isUpdating) + assertEquals(listOf(ProfileEditEvent.UpdateFailure), events) + } + + @Test + fun `수정 요청이 실패해도 고른 사진은 남아 다시 저장할 수 있다`() = + runTest(dispatcher) { + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> throw IOException("offline") } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + val events = collectEvents(viewModel) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertFalse(viewModel.success().isUpdating) + assertEquals(listOf(ProfileEditEvent.UpdateFailure), events) + } + + @Test + fun `저장 중에 고른 사진은 받지 않아 올라가는 사진과 보이는 사진이 갈리지 않는다`() = + runTest(dispatcher) { + val pendingUpdate = CompletableDeferred() + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val profileRepository = + FakeMyProfileRepository(profile = SAVED).apply { + onUpdateMyProfile = { _, _, _ -> pendingUpdate.await() } + } + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + + viewModel.selectProfileImage(PICKED_PHOTO) + viewModel.updateProfile(name = "새 이름", phone = "01011112222") + runCurrent() + assertTrue(viewModel.success().isUpdating) + + viewModel.selectProfileImage(OTHER_PHOTO) + + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + pendingUpdate.complete(SAVED) + runCurrent() + } + + private fun TestScope.collectEvents(viewModel: ProfileEditViewModel): List { + val events = mutableListOf() + backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + return events + } + + private fun ProfileEditViewModel.success(): ProfileEditUiState.Success = uiState.value as ProfileEditUiState.Success + + private companion object { + const val SERVER_IMAGE_URL = "https://cdn.test/profiles/saved.jpg?signature=abc" + const val PICKED_PHOTO = "content://media/picker/0/profile/1" + const val OTHER_PHOTO = "content://media/picker/0/profile/2" + const val UPLOADED_URL = "https://cdn.test/profiles/new.jpg" + const val UPLOADED_KEY = "profiles/tmp/new.jpg" + val SAVED = User(name = "기존 이름", email = "user@afternote.local", phone = "01000000000", profileImageUrl = SERVER_IMAGE_URL) + } +} From 95dc5c3a70238993dd3d125bec63017de7bdcb12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 25 Sep 2026 18:43:22 +0900 Subject: [PATCH 017/116] =?UTF-8?q?refactor(afternote):=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=20=EA=B3=A1=C2=B7=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=20(#2166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/AfternoteEditorFormMapper.kt | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index 11d24f87be..0e11feb355 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -118,28 +118,12 @@ internal object AfternoteEditorFormMapper { memorialPhotoUrl: String? = null, memorialVideoUrl: String? = null, memorialThumbnailUrl: String? = null, - ): MemorialWritePayload { - val songs = - playlistSongs.map { song -> - MemorialSongPayload( - title = song.title, - artist = song.artist, - coverUrl = song.albumCoverUrl, - ) - } - val memorialVideo = - memorialVideoUrl?.ifBlank { null }?.let { url -> - MemorialVideoPayload( - videoUrl = url, - thumbnailUrl = memorialThumbnailUrl?.ifBlank { null }, - ) - } - return MemorialWritePayload( + ): MemorialWritePayload = + MemorialWritePayload( memorialPhotoUrl = memorialPhotoUrl?.ifBlank { null }, - songs = songs, - memorialVideo = memorialVideo, + songs = playlistSongs.map { it.toMemorialSongPayload() }, + memorialVideo = memorialVideoPayload(videoUrl = memorialVideoUrl, thumbnailUrl = memorialThumbnailUrl), ) - } fun buildCreateInput( type: AfternoteType, @@ -450,10 +434,7 @@ internal object AfternoteEditorFormMapper { leaveMessageBlocks = leaveMessageBlocks, photoUrl = memorialMedia.memorialPhotoUrl?.ifBlank { null }, video = memorialMedia.toVideoPayload(), - songs = - playlistSongs.map { song -> - MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl) - }, + songs = playlistSongs.map { it.toMemorialSongPayload() }, ) } @@ -470,14 +451,21 @@ internal object AfternoteEditorFormMapper { private fun RegisterAfternotePayload.toCredentialsSnapshot() = CredentialsSnapshot(id = accountId.ifBlank { null }, password = password.ifBlank { null }) + private fun Song.toMemorialSongPayload() = MemorialSongPayload(title = title, artist = artist, coverUrl = albumCoverUrl) + private fun MemorialMedia.toVideoPayload(): MemorialVideoPayload? = - videoUrl?.ifBlank { null }?.let { url -> - MemorialVideoPayload(videoUrl = url, thumbnailUrl = thumbnailUrl?.ifBlank { null }) - } + memorialVideoPayload(videoUrl = videoUrl, thumbnailUrl = thumbnailUrl) private fun MemorialMediaUrls.toVideoPayload(): MemorialVideoPayload? = - memorialVideoUrl?.ifBlank { null }?.let { url -> - MemorialVideoPayload(videoUrl = url, thumbnailUrl = memorialThumbnailUrl?.ifBlank { null }) + memorialVideoPayload(videoUrl = memorialVideoUrl, thumbnailUrl = memorialThumbnailUrl) + + /** 영상 주소가 비었으면 영상 슬롯 자체가 없다. 썸네일만 남은 값은 싣지 않는다. */ + private fun memorialVideoPayload( + videoUrl: String?, + thumbnailUrl: String?, + ): MemorialVideoPayload? = + videoUrl?.ifBlank { null }?.let { url -> + MemorialVideoPayload(videoUrl = url, thumbnailUrl = thumbnailUrl?.ifBlank { null }) } } From 1f325b19cf530a4eab3a93ff762327a8197d45fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 11:05:50 +0900 Subject: [PATCH 018/116] =?UTF-8?q?fix(platform):=20=EC=8A=A4=ED=83=9D=20P?= =?UTF-8?q?R=20=EC=9D=98=20merge=20queue=20=EC=9E=AC=ED=88=AC=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20REST=20merge-async=20=EB=A1=9C=20=EB=B3=B4=EB=82=B8?= =?UTF-8?q?=EB=8B=A4=20(#2177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQL enqueuePullRequest 는 스택 일원 PR 을 UNPROCESSABLE 로 거부한다(0919 #2053, 0926 #2123). live 조회에 stack { number } 를 얹고, 스택이면 PUT pulls/{n}/merge-async 에 merge_action=merge_queue, sha=판정한 head 로 넣는다. 비스택은 종전 경로 그대로다. 판정표와 마커 선기록 순서는 바꾸지 않았다. 스택 PR 실패 코멘트는 gh stack merge 를 안내한다. --- .../scripts/handle-merge-queue-dequeue.mjs | 53 ++++++++- .../handle-merge-queue-dequeue.test.mjs | 110 ++++++++++++++++-- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 1ff578ce31..9be3a6a3bb 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -13,6 +13,10 @@ // 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 // 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 // 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. +// +// 재투입 수단은 PR 이 GitHub 네이티브 스택의 일원인지로 갈린다(#2177). 스택 PR 은 GraphQL enqueuePullRequest 가 +// UNPROCESSABLE("part of a stack and must be enqueued using the asynchronous merge REST API")로 거부되므로 +// (0919 #2053, 0926 #2123 실측) REST merge-async 에 merge_action=merge_queue 로 넣는다. 판정표는 같다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -81,7 +85,15 @@ export function decide({ reason, failedJobs, requeueCount }) { const shortSha = (sha) => sha.slice(0, 7); -export function renderFailureComment({ reason, headSha, failedJobs, repository, number }) { +/** + * 스택 PR 은 gh pr merge 가 "Auto-merge is not supported for stacked pull requests" 로 거부하므로 gh stack merge 를 안내한다. + * gh stack 은 --repo 플래그가 없어(gh 2.85 실측) 저장소 체크아웃 안에서 번호로 부른다. + */ +export function manualEnqueueCommand({ repository, number, stack = false }) { + return stack ? `gh stack merge ${number} --yes` : `gh pr merge ${number} --repo ${repository}`; +} + +export function renderFailureComment({ reason, headSha, failedJobs, repository, number, stack = false }) { const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); return [ "### merge queue 방출 — merge group CI 실패", @@ -91,7 +103,7 @@ export function renderFailureComment({ reason, headSha, failedJobs, repository, "실패한 job:", ...lines, "", - `고친 뒤 \`gh pr merge ${number} --repo ${repository}\` 로 다시 투입한다.`, + `고친 뒤 \`${manualEnqueueCommand({ repository, number, stack })}\` 로 다시 투입한다.`, ].join("\n"); } @@ -160,6 +172,7 @@ export async function fetchLivePullRequest(api, repository, number) { headRefOid baseRefName mergeQueueEntry { state position } + stack { number } timelineItems(last: 5, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) { nodes { ... on AddedToMergeQueueEvent { createdAt } } } @@ -171,6 +184,11 @@ export async function fetchLivePullRequest(api, repository, number) { return data.repository.pullRequest; } +/** GitHub 네이티브 스택의 일원인가. 비스택 PR 은 stack 이 null 로 온다(0926 #2176, #2171 실측). */ +export function isStackMember(pullRequest) { + return pullRequest?.stack != null; +} + /** 마지막으로 큐에 들어간 시각 — 그 뒤의 merge_group run 만 이번 방출의 근거다. */ export function lastEnqueuedAt(pullRequest) { const stamps = (pullRequest.timelineItems?.nodes ?? []).map((node) => node?.createdAt).filter(Boolean); @@ -219,6 +237,23 @@ export async function enqueue(api, pullRequestId) { return data.enqueuePullRequest.mergeQueueEntry; } +/** + * 스택 PR 의 큐 투입. GraphQL enqueuePullRequest 는 스택 PR 을 거부하므로 REST 비동기 머지에 + * merge_action=merge_queue 를 준다. GitHub 은 요청한 PR 과 스택에서 그 아래 PR 을 함께 큐에 넣는다. + * sha 는 판정한 head 로 고정한다. 그 사이 새 커밋이 올라왔으면 409 로 실패하고, 새 head 는 마커 0 이라 + * 다음 방출에서 다시 판정된다. 응답 status 가 failed 면 GraphQL 오류와 같이 예외로 올려 job 을 red 로 남긴다. + */ +export async function enqueueStacked(api, repository, number, headSha) { + const result = await api(`/repos/${repository}/pulls/${number}/merge-async`, { + method: "PUT", + body: { merge_action: "merge_queue", sha: headSha }, + }); + if (result?.status === "failed") { + throw new Error(`merge-async 실패: ${JSON.stringify(result)}`); + } + return result; +} + export async function handleDequeue({ api, repository, number, reason, dryRun = false, logger = console }) { const live = await fetchLivePullRequest(api, repository, number); if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); @@ -232,24 +267,30 @@ export async function handleDequeue({ api, repository, number, reason, dryRun = } const headSha = live.headRefOid; + const stack = isStackMember(live); const since = lastEnqueuedAt(live); const failedJobs = await fetchQueueFailedJobs(api, repository, { baseRef: live.baseRefName, number, since }); const comments = await fetchComments(api, repository, number); const requeueCount = countRequeuesForHead(comments, headSha); const action = decide({ reason, failedJobs, requeueCount }); - logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); + logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 스택=${stack ? live.stack.number : "없음"} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); if (dryRun) return { action, dryRun: true, failedJobs, requeueCount }; if (action === "comment-failure") { - await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number })); + await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number, stack })); } else if (action === "comment-give-up") { await postComment(api, repository, number, renderGiveUpComment({ reason, headSha })); } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); - const entry = await enqueue(api, live.id); - logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + if (stack) { + const result = await enqueueStacked(api, repository, number, headSha); + logger.log(`#${number} 스택 ${live.stack.number} 재투입(merge-async) → ${result?.status ?? "?"} ${result?.details?.message ?? ""}`.trimEnd()); + } else { + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + } } return { action, failedJobs, requeueCount }; } diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index f09f58d9a2..8b6743a355 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,8 +7,11 @@ import { collectFailedJobs, countRequeuesForHead, decide, + enqueueStacked, handleDequeue, + isStackMember, lastEnqueuedAt, + manualEnqueueCommand, queueBranchPrefix, renderFailureComment, renderRequeueComment, @@ -109,9 +112,32 @@ test("실패 코멘트는 job 링크와 재투입 명령을 담고, 재투입했 assert.ok(!body.includes(requeueMarker(HEAD))); }); +test("스택 PR 의 실패 코멘트는 gh pr merge 가 아니라 gh stack merge 를 안내한다 — gh pr merge 는 스택 PR 을 거부한다", () => { + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123 }), "gh pr merge 2123 --repo Afternote/Afternote-FE"); + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123, stack: true }), "gh stack merge 2123 --yes", "gh stack 에는 --repo 가 없다"); + const body = renderFailureComment({ + reason: "CI_FAILURE", + headSha: HEAD, + repository: REPO, + number: 2123, + stack: true, + failedJobs: [{ runName: "Unit Test", jobName: "Run Unit Tests", jobUrl: "https://x/jobs/11", conclusion: "failure" }], + }); + assert.match(body, /gh stack merge 2123 --yes/); + assert.ok(!body.includes("gh pr merge")); +}); + +// ---- 스택 판정 (#2177) ---- + +test("stack 이 null·없음이면 비스택, 객체면 스택이다", () => { + assert.equal(isStackMember({ stack: null }), false); + assert.equal(isStackMember({}), false); + assert.equal(isStackMember({ stack: { number: 2125 } }), true); +}); + // ---- handleDequeue 흐름: live 상태가 정본, 쓰기 순서 ---- -function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { +function fakeApi({ live, runs = [], jobs = {}, comments = [], mergeAsync = { status: "enqueued", details: { message: "Pull request added to merge queue" } } }) { const calls = []; const api = async (apiPath, options = {}) => { calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); @@ -122,6 +148,7 @@ function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { } return { data: { repository: { pullRequest: live } } }; } + if (/\/pulls\/\d+\/merge-async$/.test(apiPath) && options.method === "PUT") return mergeAsync; if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; @@ -133,15 +160,23 @@ function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { return api; } +const enqueueMutations = (api) => api.calls.filter((call) => call.body?.query?.includes("enqueuePullRequest")); +const mergeAsyncCalls = (api) => api.calls.filter((call) => call.apiPath.endsWith("/merge-async")); +const commentPosts = (api) => api.calls.filter((call) => call.method === "POST" && call.apiPath.endsWith("/comments")); + const openLive = { id: "PR_1", state: "OPEN", headRefOid: HEAD, baseRefName: "develop", mergeQueueEntry: null, + stack: null, timelineItems: { nodes: [{ createdAt: "2026-09-04T08:00:00Z" }] }, }; +// 0926 #2123: base 는 develop 이지만 위에 #2124 가 얹혀 스택 2125 의 밑단이다. +const stackedLive = { ...openLive, stack: { number: 2125 } }; + test("MERGED·CLOSED 거나 이미 큐에 있으면 payload 와 무관하게 아무것도 쓰지 않는다", async () => { for (const live of [ { ...openLive, state: "MERGED" }, @@ -165,6 +200,64 @@ test("조용한 방출: 마커 코멘트를 먼저 남기고 그 다음 enqueueP assert.match(writes[0].apiPath, /\/issues\/1509\/comments$/); assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD))); assert.equal(writes[1].apiPath, "/graphql"); + assert.equal(mergeAsyncCalls(api).length, 0, "비스택 PR 은 종전대로 GraphQL 로만 넣는다"); +}); + +// ---- 스택 PR 의 재투입은 REST merge-async (#2177) ---- + +test("live 조회는 stack 필드를 함께 읽는다 — 재투입 수단을 고르는 근거다", async () => { + const api = fakeApi({ live: openLive }); + await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", dryRun: true, logger: silent }); + const liveQuery = api.calls.find((call) => call.apiPath === "/graphql").body.query; + assert.match(liveQuery, /stack\s*\{\s*number\s*\}/); +}); + +test("스택 PR 의 조용한 방출: 마커 코멘트 뒤 merge-async(merge_action=merge_queue, sha=head) 를 쏘고 enqueuePullRequest 는 쏘지 않는다", async () => { + const api = fakeApi({ live: stackedLive }); + const result = await handleDequeue({ api, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(enqueueMutations(api).length, 0, "GraphQL enqueuePullRequest 는 스택 PR 을 UNPROCESSABLE 로 거부한다"); + const writes = api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql"); + assert.equal(writes.length, 2); + assert.match(writes[0].apiPath, /\/issues\/2123\/comments$/); + assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD)), "마커가 먼저다 — 재투입 뒤 코멘트가 실패하면 두 번째 재투입이 나간다"); + assert.deepEqual( + { apiPath: writes[1].apiPath, method: writes[1].method, body: writes[1].body }, + { apiPath: `/repos/${REPO}/pulls/2123/merge-async`, method: "PUT", body: { merge_action: "merge_queue", sha: HEAD } }, + ); +}); + +test("스택 여부는 판정표를 바꾸지 않는다 — 실패 job 이면 comment-failure, 같은 head 두 번째면 comment-give-up, 어느 쪽도 재투입 없음", async () => { + const runs = [{ id: 7, name: "Unit Test", html_url: "https://x/runs/7", head_branch: queueBranchPrefix("develop", 2123) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 7: [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/71" }] }; + const failed = fakeApi({ live: stackedLive, runs, jobs }); + assert.equal((await handleDequeue({ api: failed, repository: REPO, number: 2123, reason: "CI_FAILURE", logger: silent })).action, "comment-failure"); + assert.match(commentPosts(failed)[0].body.body, /gh stack merge 2123 --yes/); + + const second = fakeApi({ live: stackedLive, comments: [{ body: renderRequeueComment({ reason: "MERGE_CONFLICT", headSha: HEAD }) }] }); + assert.equal((await handleDequeue({ api: second, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent })).action, "comment-give-up"); + + const manual = fakeApi({ live: stackedLive }); + assert.equal((await handleDequeue({ api: manual, repository: REPO, number: 2123, reason: "MANUAL", logger: silent })).action, "none"); + + for (const api of [failed, second, manual]) { + assert.equal(mergeAsyncCalls(api).length, 0); + assert.equal(enqueueMutations(api).length, 0); + } + assert.equal(commentPosts(failed).length, 1); + assert.equal(commentPosts(second).length, 1); + assert.equal(commentPosts(manual).length, 0); +}); + +test("merge-async 응답 status 가 failed 면 GraphQL 오류처럼 예외로 올린다 — job 이 red 로 남아 사람이 본다", async () => { + const api = fakeApi({ live: stackedLive, mergeAsync: { status: "failed", details: { message: "Pull request is not mergeable" } } }); + await assert.rejects( + handleDequeue({ api, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent }), + /merge-async 실패: .*not mergeable/, + ); + assert.equal(mergeAsyncCalls(api).length, 1); + await assert.rejects(enqueueStacked(api, REPO, 2123, HEAD), /merge-async 실패/); + assert.equal(mergeAsyncCalls(api).length, 2); }); test("같은 head 의 두 번째 방출은 코멘트만 남기고 enqueuePullRequest 를 쏘지 않는다", async () => { @@ -186,12 +279,15 @@ test("CI 실패: 실패 job 링크 코멘트만, 재투입 없음", async () => assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); }); -test("dry-run 은 판정만 하고 아무것도 쓰지 않는다", async () => { - const api = fakeApi({ live: openLive }); - const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); - assert.equal(result.action, "requeue"); - assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); - assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +test("dry-run 은 판정만 하고 아무것도 쓰지 않는다 — 스택 PR 도 같다", async () => { + for (const live of [openLive, stackedLive]) { + const api = fakeApi({ live }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql").length, 0); + assert.equal(enqueueMutations(api).length, 0); + assert.equal(mergeAsyncCalls(api).length, 0); + } }); // ---- 워크플로 정책 ---- From d8b6d1db7d180e73d098496667efec02549953fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 11:14:42 +0900 Subject: [PATCH 019/116] =?UTF-8?q?fix(platform):=20merge-async=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=ED=8F=B4=EB=A7=81=ED=95=B4=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=98=EA=B3=A0=20=EC=8A=A4=ED=83=9D=20?= =?UTF-8?q?=EC=9C=84=EC=AA=BD=20PR=20=EC=9D=80=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#2177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-async 는 202 pending 과 uuid 를 돌려주고 결과는 GET merge-async/{uuid} 로만 보인다. 5초 간격 최대 24회 폴링해 enqueued·merged 면 성공, failed 면 예외로 job 을 red 로 남긴다. 끝까지 pending 이면 live 큐 상태를 다시 읽어 없으면 예외다. merge-async 는 downstack 의 열린 PR 까지 함께 넣으므로, 스택 위쪽 PR 의 재투입은 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮는다. PR base 가 스택 base 와 같은 밑단만 재투입하고, 위쪽은 마커 없는 안내 코멘트만 남긴다. decide() 판정표는 그대로다. give-up 코멘트에 스택 여부에 맞는 수동 투입 명령을 붙이고, 결과와 스텝 요약에 스택 번호와 재투입 수단을 남긴다. --- .../scripts/handle-merge-queue-dequeue.mjs | 96 ++++-- .../handle-merge-queue-dequeue.test.mjs | 317 ++++++++++++++++-- 2 files changed, 358 insertions(+), 55 deletions(-) diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 9be3a6a3bb..b1498e601d 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -17,6 +17,9 @@ // 재투입 수단은 PR 이 GitHub 네이티브 스택의 일원인지로 갈린다(#2177). 스택 PR 은 GraphQL enqueuePullRequest 가 // UNPROCESSABLE("part of a stack and must be enqueued using the asynchronous merge REST API")로 거부되므로 // (0919 #2053, 0926 #2123 실측) REST merge-async 에 merge_action=merge_queue 로 넣는다. 판정표는 같다. +// merge-async 는 202 pending 으로 받으므로 결과(enqueued·merged·failed)를 폴링해 확인하고, failed·미확정은 job 을 +// red 로 남긴다. 스택 위쪽 PR 은 자동 재투입하지 않는다 — merge-async 가 downstack 의 열린 PR 까지 함께 넣어 +// 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮기 때문이다. 위쪽 PR 에는 마커 없는 안내 코멘트만 남긴다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -28,6 +31,7 @@ export const REQUEUE_MARKER_PREFIX = " + 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt index 24b699a0ec..d43e9cf127 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt @@ -21,10 +21,12 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.asString +import com.afternote.core.ui.auth.PasswordRuleItem import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R +import com.afternote.core.ui.R as CoreUiR /** * 비밀번호 변경 화면의 상태 없는 본문. @@ -101,16 +103,9 @@ internal fun PasswordChangeContent( onImeAction = { onIntent(PasswordChangeIntent.Submit) }, ) - Text( - text = stringResource(R.string.setting_password_change_rule), - modifier = Modifier.fillMaxWidth(), - style = AfternoteDesign.typography.captionLargeB, - color = - if (uiState.isNewPasswordRuleSatisfied) { - AfternoteDesign.colors.b1 - } else { - AfternoteDesign.colors.gray5 - }, + PasswordRuleItem( + text = stringResource(CoreUiR.string.core_ui_password_rule_combination), + isSatisfied = uiState.isNewPasswordRuleSatisfied, ) uiState.errorMessage?.let { message -> diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c581..d594c9e076 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -175,7 +175,6 @@ 현재 비밀번호를 입력해주세요 새 비밀번호 새 비밀번호를 입력해주세요 - 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. 변경하기 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. From c773d6dbc9948cd6385eb744c9e8d40d8b88600f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 22:58:41 +0900 Subject: [PATCH 028/116] =?UTF-8?q?refactor(core,setting):=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=ED=97=AC=ED=8D=BC=EB=A5=BC=20core:common=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4=20(#2194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 KakaoLinkHelper.kt 의 requestKakaoAccessToken 을 core:common auth 패키지로 옮기고 동작·예외 문구·취소 판정(CoreAuthFailure.UserCancelledAuth)은 그대로 둔다. 연결된 계정 화면은 import 만 core 로 바꾸고, 설정 모듈에서 쓰지 않게 된 카카오 SDK 의존을 걷는다. --- core/common/build.gradle.kts | 3 +++ .../core/common/auth/KakaoAccessToken.kt | 22 +++++++++++++++++-- feature/setting/presentation/build.gradle.kts | 2 -- .../account/ConnectedAccountsScreen.kt | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) rename feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt => core/common/src/main/kotlin/com/afternote/core/common/auth/KakaoAccessToken.kt (58%) diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index a227880794..ac0f991839 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -10,7 +10,10 @@ android { } dependencies { + implementation(projects.core.domain) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.kakao.sdk.auth) + implementation(libs.kakao.sdk.user) // BiometricCryptoGate 가 BiometricPrompt.CryptoObject 를 공개 시그니처로 돌려주므로 // 소비 모듈의 컴파일 클래스패스에도 올라가야 한다 — implementation 이면 반환 타입을 못 본다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt b/core/common/src/main/kotlin/com/afternote/core/common/auth/KakaoAccessToken.kt similarity index 58% rename from feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt rename to core/common/src/main/kotlin/com/afternote/core/common/auth/KakaoAccessToken.kt index 7a43358266..facdc58dcc 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/social/KakaoLinkHelper.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/auth/KakaoAccessToken.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.setting.presentation.account.social +package com.afternote.core.common.auth import android.app.Activity import com.afternote.core.domain.error.CoreAuthFailure @@ -9,8 +9,22 @@ import com.kakao.sdk.user.UserApiClient import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume -suspend fun requestKakaoAccessToken(activity: Activity): Result = +/** + * 카카오 SDK로부터 OAuth 액세스 토큰을 획득한다. + * + * 플랫폼 SDK가 [Activity] 컨텍스트를 요구하므로 이 함수는 UI 레이어(화면)에서 호출한다. + * Data 레이어(Repository)에는 획득한 액세스 토큰 문자열만 전달되어, + * Data 레이어가 UI 컴포넌트(Activity)에 의존하지 않도록 한다. + * 카카오 로그인(온보딩)과 카카오 계정 연결(설정)이 같은 흐름을 쓰므로 feature 가 아닌 core 에 한 벌을 둔다 (#2194). + * + * 디바이스에 카카오톡이 설치된 경우 앱 로그인을, 그 외에는 웹 계정 로그인을 수행하며, + * 앱 로그인이 비정상 종료된 경우에는 웹 계정 로그인으로 폴백한다. + * 사용자가 로그인을 취소하면 폴백 없이 [CoreAuthFailure.UserCancelledAuth] 로 실패한다. + * 콜백 기반 SDK API를 [suspendCancellableCoroutine]으로 감싸 코루틴 흐름으로 변환한다. + */ +public suspend fun requestKakaoAccessToken(activity: Activity): Result = suspendCancellableCoroutine { continuation -> + // 공통 콜백: 카카오톡 앱 로그인·웹 계정 로그인 결과를 모두 처리한다. val callback: (OAuthToken?, Throwable?) -> Unit = { token, error -> if (continuation.isActive) { when { @@ -36,20 +50,24 @@ suspend fun requestKakaoAccessToken(activity: Activity): Result = } if (UserApiClient.instance.isKakaoTalkLoginAvailable(activity)) { + // 카카오톡 앱이 설치된 경우: 앱 로그인 시도 UserApiClient.instance.loginWithKakaoTalk(activity) { token, error -> if (error != null) { + // 사용자가 의도적으로 취소한 경우에는 폴백 없이 즉시 실패 처리 if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { if (continuation.isActive) { continuation.resume(Result.failure(CoreAuthFailure.UserCancelledAuth())) } return@loginWithKakaoTalk } + // 그 외 에러(네트워크·앱 오류 등)는 웹 계정 로그인으로 폴백 UserApiClient.instance.loginWithKakaoAccount(activity, callback = callback) } else { callback(token, null) } } } else { + // 카카오톡 앱이 없는 경우: 곧바로 웹 계정 로그인 UserApiClient.instance.loginWithKakaoAccount(activity, callback = callback) } } diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 4eb2c8004c..abc770b98c 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.runtime) - implementation(libs.kakao.sdk.auth) - implementation(libs.kakao.sdk.user) implementation(libs.androidx.biometric) implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 6e6d6295f4..38eafcf3a1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -12,13 +12,13 @@ import androidx.compose.ui.res.stringResource import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.common.auth.requestKakaoAccessToken import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken -import com.afternote.feature.setting.presentation.account.social.requestKakaoAccessToken import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult @Composable From 6f2f98006237eb7397c45995242f066f08ef3c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 22:58:41 +0900 Subject: [PATCH 029/116] =?UTF-8?q?refactor(afternote):=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B4=9D=20N?= =?UTF-8?q?=EA=B3=A1=20=ED=97=A4=EB=8D=94=20=EC=82=AC=EB=B3=B8=EC=9D=84=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=ED=95=A9=EC=B9=9C=EB=8B=A4=20(#2?= =?UTF-8?q?196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 전체보기와 발신자 추억 플레이리스트가 각자 두던 "총 N곡" 헤더를 shared/detail 의 PlaylistSongCountHeader 하나로 옮겼다. 값이 같던 문자열 두 키는 afternote_playlist_song_count_format 하나로 합치고, 사본을 정당화하던 KDoc 을 사실에 맞게 고쳤다. --- .../editor/memorial/MemorialPlaylistEntry.kt | 38 +++---------------- .../playlist/ReceiverPlayListScreen.kt | 33 ++-------------- .../shared/detail/PlaylistSongList.kt | 32 +++++++++++++++- .../src/main/res/values/strings.xml | 3 +- .../playlist/ReceiverPlaylistHeaderTest.kt | 2 +- 5 files changed, 40 insertions(+), 68 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntry.kt index 0927de9396..c1c664a57c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/MemorialPlaylistEntry.kt @@ -1,14 +1,11 @@ package com.afternote.feature.afternote.presentation.editor.memorial import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,9 +17,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.afternote.core.ui.button.FAB.PenFloatingActionButton -import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.shared.detail.PlaylistEmptyContent +import com.afternote.feature.afternote.presentation.shared.detail.PlaylistSongCountHeader import com.afternote.feature.afternote.presentation.shared.detail.PlaylistSongList import com.afternote.feature.afternote.presentation.shared.detail.SelectableSongListBody import com.afternote.feature.afternote.presentation.shared.detail.SongPlaylistFloatingActionSlot @@ -43,8 +40,8 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * selectable 본문에 상시 노출 파라미터를 되살려 스왑. * * 이 조립을 흐름 호스트(`AfternoteEditorFlowHost`)의 entry 블록에 인라인하지 않고 Entry로 빼는 이유: - * (1) 공용 SongPlaylist 화면 계열(shared/detail/song)은 여러 화면이 공유하는 범용 부품이라 - * 이 화면 전용 지식([Song] 도메인 매핑, 타이틀, 모드 전환·"총 N곡" 헤더·삭제 라벨·콜백)을 넣을 수 없고, + * (1) 공용 SongPlaylist 화면 계열(shared/detail)은 여러 화면이 공유하는 범용 부품이라 + * 이 화면 전용 지식([Song] 도메인 매핑, 타이틀, 모드 전환·삭제 라벨·콜백)을 넣을 수 없고, * (2) 그 전용 지식을 entry 블록에 인라인하면 ViewModel 없이 렌더할 수 없어 * Preview·스크린샷 테스트가 막힌다. * Entry가 둘 사이에서 도메인→표시 모델 매핑과 화면 전용 크롬 주입을 맡는 어댑터다. @@ -100,7 +97,7 @@ fun MemorialPlaylistEntry( modifier = Modifier.padding(paddingValues), songs = displaySongs, header = { - MemorialPlaylistListHeader(songCount = displaySongs.size) + PlaylistSongCountHeader(songCount = displaySongs.size) }, initialSelectedSongKeys = initialSelectedSongKeys, actionLabel = stringResource(R.string.afternote_editor_playlist_delete_all), @@ -114,7 +111,7 @@ fun MemorialPlaylistEntry( modifier = Modifier.fillMaxSize(), songs = displaySongs, header = { - MemorialPlaylistListHeader(songCount = displaySongs.size) + PlaylistSongCountHeader(songCount = displaySongs.size) }, ) if (displaySongs.isEmpty()) { @@ -130,28 +127,3 @@ fun MemorialPlaylistEntry( } } } - -/** - * MemorialPlaylistList 화면 상단 헤더: 모드와 무관하게 항상 "총 N곡"만 왼쪽에 표시. - */ -@Composable -private fun MemorialPlaylistListHeader( - songCount: Int, - modifier: Modifier = Modifier, -) { - // 헤더 상단 간격은 헤더별로 달라(검색 16 / 곡 수 8) 리스트가 아닌 각 헤더가 top 여백을 소유한다. - Column( - modifier = - modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) { - Text( - text = stringResource(R.string.afternote_editor_playlist_song_count_format, songCount), - style = - AfternoteDesign.typography.bodySmallR.copy( - color = AfternoteDesign.colors.gray9, - ), - ) - } -} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt index ac4b8264f7..55ff069f26 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlayListScreen.kt @@ -1,18 +1,14 @@ package com.afternote.feature.afternote.presentation.receiver.playlist import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.shared.detail.PlaylistEmptyContent +import com.afternote.feature.afternote.presentation.shared.detail.PlaylistSongCountHeader import com.afternote.feature.afternote.presentation.shared.detail.PlaylistSongList import com.afternote.feature.afternote.presentation.shared.detail.SongPlaylistScaffold import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay @@ -24,7 +20,7 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * (발신자 화면의 "추억 플레이리스트" 와 달리 유족 관점, #274). * * 공용 부품([SongPlaylistScaffold] + [PlaylistSongList])을 직접 조립한다. 헤더는 발신자 열람 화면 - * (MemorialPlaylistEntry 목록 모드)과 같은 "총 N곡" 이다 — 곡 검색 헤더는 곡을 골라 담는 화면 + * (MemorialPlaylistEntry 목록 모드)과 같은 [PlaylistSongCountHeader] 다 — 곡 검색 헤더는 곡을 골라 담는 화면 * (AddSongScreen)의 것이라, 열람만 하는 수신자에게는 고인의 플레이리스트에 곡을 더할 수 있는 것처럼 * 보였다 (#620). */ @@ -49,7 +45,7 @@ fun MemorialPlaylistScreen( PlaylistSongList( modifier = Modifier.fillMaxSize(), songs = songs, - header = { ReceiverPlaylistSongCountHeader(songCount = songs.size) }, + header = { PlaylistSongCountHeader(songCount = songs.size) }, ) if (songs.isEmpty()) { PlaylistEmptyContent( @@ -60,26 +56,3 @@ fun MemorialPlaylistScreen( } } } - -/** 발신자 열람 화면의 "총 N곡" 헤더와 같은 자리·같은 문안. 모듈이 달라 사본을 둔다. */ -@Composable -private fun ReceiverPlaylistSongCountHeader( - songCount: Int, - modifier: Modifier = Modifier, -) { - // 헤더 상단 간격은 헤더별로 다르다 — 곡 수 헤더는 8dp (발신자 화면과 동일). - Column( - modifier = - modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) { - Text( - text = stringResource(R.string.afternote_receiver_playlist_song_count_format, songCount), - style = - AfternoteDesign.typography.bodySmallR.copy( - color = AfternoteDesign.colors.gray9, - ), - ) - } -} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/PlaylistSongList.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/PlaylistSongList.kt index 9af5bbf120..783a85d809 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/PlaylistSongList.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/PlaylistSongList.kt @@ -36,7 +36,7 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * * 이 리스트는 필터도 검색도 소유하지 않는다 — [songs] 를 받은 그대로 그리고, 상단 헤더는 호출부가 * [header] 로 주입한다(필수 — 모든 호출부가 헤더를 가진다). 곡을 골라 담는 화면(AddSong)은 - * [SongSearchSection] 을 헤더로 주입하고, 열람·관리 화면은 "총 N곡" 헤더를 주입한다 — 즉 검색은 "여러 + * [SongSearchSection] 을 헤더로 주입하고, 열람·관리 화면은 [PlaylistSongCountHeader] 를 주입한다 — 즉 검색은 "여러 * 헤더 중 하나"일 뿐 리스트의 고정 지식이 아니다. 리스트가 내부 필터·검색을 갖던 시절엔 API 결과 * 이중 필터·죽은 검색 바인딩 문제가 있어 둘 다 호출부로 넘겼다 (2026-07). * 구명 SearchableSongList — addsong 패키지 태생이라 "추가용 검색" 함의가 남아 @@ -110,7 +110,7 @@ private fun rememberSongSearchFieldState( /** * 곡 검색 헤더 (label + 입력창). [PlaylistSongList] 의 헤더 슬롯에 주입해 쓰는 여러 헤더 중 하나 — - * view-only(수신자 열람)·selectable(노래 추가)이 주입한다. 관리 모드는 대신 "총 N곡" 헤더를 주입한다. + * selectable(노래 추가)이 주입한다. 열람·관리 화면은 대신 [PlaylistSongCountHeader] 를 주입한다. */ @Composable fun SongSearchSection( @@ -144,4 +144,32 @@ fun SongSearchSection( } } +/** + * "총 N곡" 헤더. [PlaylistSongList] 의 헤더 슬롯에 주입해 쓰는 여러 헤더 중 하나 — 발신자 추억 + * 플레이리스트(MemorialPlaylistEntry 목록·편집 모드)와 수신자 플레이리스트 전체보기 + * (MemorialPlaylistScreen)가 주입한다. 수신자 헤더는 발신자 열람 헤더와 같아야 해서(#620) + * 두 화면이 이 하나를 쓴다. + */ +@Composable +internal fun PlaylistSongCountHeader( + songCount: Int, + modifier: Modifier = Modifier, +) { + // 헤더 상단 간격은 헤더별로 달라(검색 16 / 곡 수 8) 리스트가 아닌 각 헤더가 top 여백을 소유한다. + Column( + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + Text( + text = stringResource(R.string.afternote_playlist_song_count_format, songCount), + style = + AfternoteDesign.typography.bodySmallR.copy( + color = AfternoteDesign.colors.gray9, + ), + ) + } +} + // endregion diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 682f225139..252bd3a189 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ 애프터노트 노래 검색 노래 검색하기 + 총 %1$d곡 추가하기 앨범 커버 @@ -138,7 +139,6 @@ 검색 결과가 없습니다. 추억 플레이리스트 플레이리스트 추가 - 총 %1$d곡 아직 추가한 곡이 없어요.\n아래 연필 버튼을 눌러 곡을 추가해 보세요. 전체 삭제 선택 삭제 @@ -196,5 +196,4 @@ 플레이리스트를 불러오지 못했습니다. %1$s님의 플레이리스트 고인이 남긴 곡이 없어요. - 총 %1$d곡 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlaylistHeaderTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlaylistHeaderTest.kt index 7f5a70b33f..6baf1ae5ef 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlaylistHeaderTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverPlaylistHeaderTest.kt @@ -57,7 +57,7 @@ class ReceiverPlaylistHeaderTest { fun `수신자 플레이리스트 헤더는 곡 수를 보여준다`() { renderScreen() - val songCount = composeRule.activity.getString(R.string.afternote_receiver_playlist_song_count_format, songs.size) + val songCount = composeRule.activity.getString(R.string.afternote_playlist_song_count_format, songs.size) composeRule.onNodeWithText(songCount).assertIsDisplayed() } } From ad9198d430431230c2d88ca7b467b75e7db86c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 23:08:48 +0900 Subject: [PATCH 030/116] =?UTF-8?q?refactor(core):=20=EC=83=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EB=9E=98=ED=8D=BC=EB=A5=BC=20core:common=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=98=AC=EB=A6=B0=EB=8B=A4=20(#2195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit afternote 의 authenticateBiometric·BiometricAuthResult·BiometricMessages 를 동작 그대로 core:common biometric 패키지로 옮기고, 설정 패스키의 private authenticate 사본을 지워 core 래퍼를 부르게 했다. 설정 쪽 하드코딩 문구는 값 그대로 setting_biometric_* 리소스로 옮기고, 나머지 문구는 afternote 와 같은 문장으로 두었다. 설정 패스키는 이로써 canAuthenticate 사전 분기와 초기화 예외 처리를 얻어, 지원하지 않는 조합에서 크래시 대신 오류로 끝난다. --- .../core/common/biometric}/BiometricAuth.kt | 20 ++-- .../afternote/presentation/build.gradle.kts | 1 - .../fingerprint/FingerprintLoginRoute.kt | 3 + .../passkey/PassKeyMakingScreen.kt | 108 ++++-------------- .../src/main/res/values/strings.xml | 10 ++ 5 files changed, 43 insertions(+), 99 deletions(-) rename {feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint => core/common/src/main/kotlin/com/afternote/core/common/biometric}/BiometricAuth.kt (93%) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt similarity index 93% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt rename to core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt index 61d89e62eb..535c0cfe86 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/BiometricAuth.kt +++ b/core/common/src/main/kotlin/com/afternote/core/common/biometric/BiometricAuth.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.shared.fingerprint +package com.afternote.core.common.biometric import android.util.Log import androidx.biometric.BiometricManager @@ -7,21 +7,19 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import com.afternote.core.common.biometric.confirmWithCryptoOperation -import com.afternote.core.common.biometric.createBiometricCryptoObject -import com.afternote.core.common.biometric.isBiometricCryptoSupported import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlin.coroutines.resume -internal const val LOG_TAG = "FingerprintLogin" +private const val LOG_TAG = "BiometricAuth" /** * 생체 인식 프롬프트에 노출할 상황별 문구. * `stringResource`로 해석된 값이 필요하므로 Composable에서 빌드해 주입한다. + * 문구는 소비 feature 마다 다르므로 core 는 값을 정하지 않고 호출 측이 넘긴다. */ -data class BiometricMessages( +public data class BiometricMessages( val initFailed: String, val noHardware: String, val noneEnrolled: String, @@ -36,12 +34,12 @@ data class BiometricMessages( * UI 레이어에서 단방향으로 흘려 성공·취소·오류를 명시적으로 분기한다. * 취소는 오류로 노출하지 않도록 별도 타입으로 정의한다. */ -sealed interface BiometricAuthResult { - data object Success : BiometricAuthResult +public sealed interface BiometricAuthResult { + public data object Success : BiometricAuthResult - data object Canceled : BiometricAuthResult + public data object Canceled : BiometricAuthResult - data class Error( + public data class Error( val message: String, ) : BiometricAuthResult } @@ -60,7 +58,7 @@ sealed interface BiometricAuthResult { * - 성공은 콜백의 boolean 이 아니라 [confirmWithCryptoOperation] 의 암호 연산 성사로 확정한다. * 프롬프트를 거치지 않고 성공 콜백만 가로챈 경우 사용자 인증에 묶인 키를 쓸 수 없어 연산이 실패한다. */ -suspend fun FragmentActivity.authenticateBiometric( +public suspend fun FragmentActivity.authenticateBiometric( title: String, subtitle: String, messages: BiometricMessages, diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index 51fc3ec066..81f105058e 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { implementation(libs.hilt.navigation) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.biometric) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt index 5cc3dcfcf2..e617f5b2cd 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/fingerprint/FingerprintLoginRoute.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric import com.afternote.core.ui.findActivity import com.afternote.feature.afternote.presentation.R import kotlinx.coroutines.launch diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt index 89aea0918a..21235df1f8 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingScreen.kt @@ -1,8 +1,6 @@ package com.afternote.feature.setting.presentation.passkey -import android.util.Log import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -11,30 +9,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat +import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.afternote.core.common.biometric.confirmWithCryptoOperation -import com.afternote.core.common.biometric.createBiometricCryptoObject -import com.afternote.core.common.biometric.isBiometricCryptoSupported +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric import com.afternote.core.ui.findActivity import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType +import com.afternote.feature.setting.presentation.R import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -private const val LOG_TAG = "PassKeyMaking" - -private sealed interface BiometricResult { - data object Success : BiometricResult - - data object Canceled : BiometricResult - - data class Error( - val message: String, - ) : BiometricResult -} @Composable fun PassKeyMakingScreen( @@ -56,6 +41,18 @@ fun PassKeyMakingScreen( var isAuthenticating by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + val messages = + BiometricMessages( + initFailed = stringResource(R.string.setting_biometric_init_failed), + noHardware = stringResource(R.string.setting_biometric_no_hardware), + noneEnrolled = stringResource(R.string.setting_biometric_none_enrolled), + hwUnavailable = stringResource(R.string.setting_biometric_hw_unavailable), + notAvailable = stringResource(R.string.setting_biometric_not_available), + verificationFailed = stringResource(R.string.setting_biometric_verification_failed), + ) + val promptTitle = stringResource(R.string.setting_biometric_prompt_title) + val promptSubtitle = stringResource(R.string.setting_biometric_prompt_subtitle) + if (showCompletionDialog) { Popup( type = PopupType.Default, @@ -84,17 +81,17 @@ fun PassKeyMakingScreen( isAuthenticating = true coroutineScope.launch { try { - when (val result = authenticate(activity)) { - BiometricResult.Success -> { + when (val result = activity.authenticateBiometric(promptTitle, promptSubtitle, messages)) { + BiometricAuthResult.Success -> { viewModel.savePasskeyRegistered() showCompletionDialog = true } - BiometricResult.Canceled -> { + BiometricAuthResult.Canceled -> { Unit } - is BiometricResult.Error -> { + is BiometricAuthResult.Error -> { errorMessage = result.message } } @@ -109,66 +106,3 @@ fun PassKeyMakingScreen( modifier = modifier, ) } - -private suspend fun authenticate(activity: FragmentActivity): BiometricResult = - suspendCancellableCoroutine { continuation -> - val authenticators = - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - - val promptInfo = - BiometricPrompt.PromptInfo - .Builder() - .setTitle("패스키 등록") - .setSubtitle("패스키로 사용할 지문을 인식해 주세요.") - .setAllowedAuthenticators(authenticators) - .build() - - // API 26~29 에서는 DEVICE_CREDENTIAL 허용자와 CryptoObject 를 함께 쓸 수 없다. - // 이때는 관문 없이 인증하고, 성공 확정은 cipher 부재로 통과시킨다 (#1166). - val cryptoObject = if (isBiometricCryptoSupported) createBiometricCryptoObject() else null - - val biometricPrompt = - BiometricPrompt( - activity, - ContextCompat.getMainExecutor(activity), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - if (!continuation.isActive) return - // 성공은 콜백의 도달이 아니라 사용자 인증에 묶인 키로 암호 연산이 - // 성사되는가로 확정한다 — 콜백만 가로챈 경우 연산이 실패한다 (#1166). - val confirmation = confirmWithCryptoOperation(result.cryptoObject?.cipher) - if (confirmation.isSuccess) { - continuation.resume(BiometricResult.Success) - } else { - Log.w(LOG_TAG, "인증 후 암호 연산 실패", confirmation.exceptionOrNull()) - continuation.resume(BiometricResult.Error("지문 확인에 실패했어요. 다시 시도해 주세요.")) - } - } - - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence, - ) { - if (!continuation.isActive) return - if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || - errorCode == BiometricPrompt.ERROR_CANCELED - ) { - continuation.resume(BiometricResult.Canceled) - } else { - continuation.resume(BiometricResult.Error(errString.toString())) - } - } - - override fun onAuthenticationFailed() {} - }, - ) - - continuation.invokeOnCancellation { biometricPrompt.cancelAuthentication() } - if (cryptoObject != null) { - biometricPrompt.authenticate(promptInfo, cryptoObject) - } else { - biometricPrompt.authenticate(promptInfo) - } - } diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c581..f2d21309b5 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -130,6 +130,16 @@ 비밀번호로 인증하기 비밀번호를 입력해주세요 + + 패스키 등록 + 패스키로 사용할 지문을 인식해 주세요. + 지문 확인에 실패했어요. 다시 시도해 주세요. + 지문 인증을 사용할 수 없습니다. + 인증 화면을 불러오지 못했습니다. 앱을 다시 시작해 주세요. + 이 기기는 생체 인식을 지원하지 않습니다. + 등록된 지문이나 화면 잠금이 없습니다. 설정에서 등록해 주세요. + 생체 인식을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요. + 패스키 관리 패스키를 만들어 비밀번호 대신 지문 및 얼굴 인식으로\n쉽고 안전하게 로그인할 수 있습니다.\n패스키는 본인 소유의 기기에서만 생성해주세요. From 52b6b2d9af14eef14365ae8b484f08b10494572f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 13:47:06 +0900 Subject: [PATCH 031/116] =?UTF-8?q?refactor(core):=20AuthMapper=20?= =?UTF-8?q?=EB=A5=BC=20DTO=20=ED=99=95=EC=9E=A5=20=ED=95=A8=EC=88=98=20toD?= =?UTF-8?q?omain=20=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4=20(#22?= =?UTF-8?q?05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/mapper/auth/AuthMapper.kt | 34 +++++++++---------- .../repoimpl/account/AccountRepositoryImpl.kt | 6 ++-- .../data/repoimpl/auth/AuthRepositoryImpl.kt | 10 +++--- .../core/data/mapper/auth/AuthMapperTest.kt | 31 +++++++---------- 4 files changed, 37 insertions(+), 44 deletions(-) diff --git a/core/data/src/main/java/com/afternote/core/data/mapper/auth/AuthMapper.kt b/core/data/src/main/java/com/afternote/core/data/mapper/auth/AuthMapper.kt index 7e98186f76..85e7d15171 100644 --- a/core/data/src/main/java/com/afternote/core/data/mapper/auth/AuthMapper.kt +++ b/core/data/src/main/java/com/afternote/core/data/mapper/auth/AuthMapper.kt @@ -9,27 +9,25 @@ import com.afternote.core.network.dto.LoginDto import com.afternote.core.network.dto.ReissueDto import com.afternote.core.network.dto.SignUpDto -/** +/* * Auth DTO를 Domain 모델로 변환. (스웨거 기준) */ -object AuthMapper { - fun toSignUpResult(dto: SignUpDto): AccountRegistration = AccountRegistration(userId = dto.userId, email = dto.email) - fun toFoundAccount(dto: EmailFindDto): FoundAccount = FoundAccount(name = dto.name, email = dto.email) +internal fun SignUpDto.toDomain(): AccountRegistration = AccountRegistration(userId = userId, email = email) - fun toDefaultLoginResult(dto: LoginDto.DefaultLoginDto): Session.DefaultSession = - Session.DefaultSession( - accessToken = dto.accessToken, - refreshToken = dto.refreshToken, - ) +internal fun EmailFindDto.toDomain(): FoundAccount = FoundAccount(name = name, email = email) - fun toSocialLoginResult(dto: LoginDto.SocialLoginDto): Session.SocialSession = - Session.SocialSession( - accessToken = dto.accessToken, - refreshToken = dto.refreshToken, - isNewUser = dto.isNewUser, - ) +internal fun LoginDto.DefaultLoginDto.toDomain(): Session.DefaultSession = + Session.DefaultSession( + accessToken = accessToken, + refreshToken = refreshToken, + ) - fun toRotateTokenResult(dto: ReissueDto): TokenBundle = - TokenBundle(accessToken = dto.accessToken, refreshToken = dto.refreshToken, expiresIn = dto.expiresIn) -} +internal fun LoginDto.SocialLoginDto.toDomain(): Session.SocialSession = + Session.SocialSession( + accessToken = accessToken, + refreshToken = refreshToken, + isNewUser = isNewUser, + ) + +internal fun ReissueDto.toDomain(): TokenBundle = TokenBundle(accessToken = accessToken, refreshToken = refreshToken, expiresIn = expiresIn) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt index 7108fce92e..ef59dfa342 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/account/AccountRepositoryImpl.kt @@ -1,7 +1,7 @@ package com.afternote.core.data.repoimpl.account import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.data.mapper.auth.AuthMapper +import com.afternote.core.data.mapper.auth.toDomain import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.model.AccountRegistration import com.afternote.core.model.FoundAccount @@ -62,7 +62,7 @@ internal class AccountRepositoryImpl certificateCode, ), ) - AuthMapper.toFoundAccount(response.requireData()) + response.requireData().toDomain() }.mapAccountFailure() override suspend fun resetPassword( @@ -99,7 +99,7 @@ internal class AccountRepositoryImpl profileUrl, ), ) - AuthMapper.toSignUpResult(response.requireData()) + response.requireData().toDomain() }.mapAccountFailure() override suspend fun passwordChange( diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index dbdf296cf9..071a039a05 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -1,7 +1,7 @@ package com.afternote.core.data.repoimpl.auth import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.data.mapper.auth.AuthMapper +import com.afternote.core.data.mapper.auth.toDomain import com.afternote.core.datastore.LocalStoreRegistry import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource @@ -77,7 +77,7 @@ internal class AuthRepositoryImpl runCatchingCancellable { val data = authApiService.login(LoginRequestDto(email, password)).requireData() recordIssuedExpiresIn(data.expiresIn) - AuthMapper.toDefaultLoginResult(data) + data.toDomain() }.mapLoginFailure() override suspend fun kakaoLogin(oauthToken: String): Result = @@ -91,7 +91,7 @@ internal class AuthRepositoryImpl ), ).requireData() recordIssuedExpiresIn(data.expiresIn) - AuthMapper.toSocialLoginResult(data) + data.toDomain() }.mapLoginFailure() override suspend fun googleLogin(idToken: String): Result = @@ -105,7 +105,7 @@ internal class AuthRepositoryImpl ), ).requireData() recordIssuedExpiresIn(data.expiresIn) - AuthMapper.toSocialLoginResult(data) + data.toDomain() }.mapLoginFailure() override suspend fun rotateToken(): Result = @@ -114,7 +114,7 @@ internal class AuthRepositoryImpl getRefreshToken().getOrNull() ?: error("리프레시 토큰이 존재하지 않습니다.") val response = tokenApiService.reissue(ReissueRequestDto(refreshToken)) - val tokenBundleResult = AuthMapper.toRotateTokenResult(response.requireData()) + val tokenBundleResult = response.requireData().toDomain() check(tokenBundleResult.accessToken.isNotEmpty()) { "Token rotation returned an empty access token" } diff --git a/core/data/src/test/java/com/afternote/core/data/mapper/auth/AuthMapperTest.kt b/core/data/src/test/java/com/afternote/core/data/mapper/auth/AuthMapperTest.kt index d352afa5ee..b7bc808a68 100644 --- a/core/data/src/test/java/com/afternote/core/data/mapper/auth/AuthMapperTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/mapper/auth/AuthMapperTest.kt @@ -12,7 +12,7 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * [AuthMapper] DTO → Domain 변환 회귀 가드. + * Auth DTO → Domain 변환 회귀 가드. * * 매퍼가 단순 필드 복사라 분기 로직은 없지만, `accessToken` ↔ `refreshToken` 자리 바뀜·필드 누락 * 같은 사고를 잡는다. 두 토큰엔 서로 다른 sentinel 값을 줘서 자리가 바뀌면 도메인 객체 equals @@ -20,26 +20,24 @@ import org.junit.Test */ class AuthMapperTest { @Test - fun `toSignUpResult - userId·email 매핑`() { - val result = AuthMapper.toSignUpResult(SignUpDto(userId = 42L, email = "user@example.com")) + fun `SignUpDto toDomain - userId·email 매핑`() { + val result = SignUpDto(userId = 42L, email = "user@example.com").toDomain() assertEquals(AccountRegistration(userId = 42L, email = "user@example.com"), result) } @Test - fun `toFoundAccount - name·email 매핑 (자리 바뀜 가드)`() { + fun `EmailFindDto toDomain - name·email 매핑 (자리 바뀜 가드)`() { // 둘 다 String 이라 자리가 바뀌어도 컴파일은 통과한다 — 서로 구분되는 값으로 가드. - val result = AuthMapper.toFoundAccount(EmailFindDto(name = "박채연", email = "parkchae01@gmail.com")) + val result = EmailFindDto(name = "박채연", email = "parkchae01@gmail.com").toDomain() assertEquals(FoundAccount(name = "박채연", email = "parkchae01@gmail.com"), result) } @Test - fun `toDefaultLoginResult - 토큰 매핑 (자리 바뀜 가드)`() { + fun `DefaultLoginDto toDomain - 토큰 매핑 (자리 바뀜 가드)`() { val result = - AuthMapper.toDefaultLoginResult( - LoginDto.DefaultLoginDto(accessToken = "access-1", refreshToken = "refresh-1"), - ) + LoginDto.DefaultLoginDto(accessToken = "access-1", refreshToken = "refresh-1").toDomain() assertEquals( Session.DefaultSession(accessToken = "access-1", refreshToken = "refresh-1"), @@ -48,18 +46,17 @@ class AuthMapperTest { } @Test - fun `toSocialLoginResult - 토큰·isNewUser 매핑`() { + fun `SocialLoginDto toDomain - 토큰·isNewUser 매핑`() { // isNewUser 는 온보딩 진입을 가르는 값이라 뒤집히면 안 된다 — true/false 왕복 확인(#993). // 토큰 자리 바뀜도 같은 케이스에서 객체 비교로 함께 가드. listOf(true, false).forEach { newUser -> val result = - AuthMapper.toSocialLoginResult( - LoginDto.SocialLoginDto( + LoginDto + .SocialLoginDto( accessToken = "access-1", refreshToken = "refresh-1", isNewUser = newUser, - ), - ) + ).toDomain() assertEquals( "isNewUser=$newUser", @@ -70,11 +67,9 @@ class AuthMapperTest { } @Test - fun `toRotateTokenResult - 토큰·expiresIn 매핑 (자리 바뀜 가드)`() { + fun `ReissueDto toDomain - 토큰·expiresIn 매핑 (자리 바뀜 가드)`() { val result = - AuthMapper.toRotateTokenResult( - ReissueDto(accessToken = "access-1", refreshToken = "refresh-1", expiresIn = 3599), - ) + ReissueDto(accessToken = "access-1", refreshToken = "refresh-1", expiresIn = 3599).toDomain() assertEquals( TokenBundle(accessToken = "access-1", refreshToken = "refresh-1", expiresIn = 3599), From 3498e610a7b80490dd7c0e6a12fd315451c8b29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 15:03:08 +0900 Subject: [PATCH 032/116] =?UTF-8?q?refactor(afternote):=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20URL=20=EB=B9=88=20=EA=B0=92=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=EB=A5=BC=20=EC=9E=85=EA=B5=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=9D=80=EB=8B=A4=20(#2207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상세 data 매퍼와 썸네일 결과 반영에서 빈 문자열을 null 로 맞추고, 폼 매퍼의 미디어 URL ifBlank 를 걷어낸다. --- .../data/mapper/AfternoteDetailMapper.kt | 25 +++++++++--------- .../data/mapper/AfternoteDetailMapperTest.kt | 26 ++++++++++++++++++- .../editor/AfternoteEditorFormMapper.kt | 16 ++++++------ .../editor/state/EditableMemorialVideo.kt | 4 +-- .../editor/state/EditableMemorialVideoTest.kt | 11 ++++++++ 5 files changed, 59 insertions(+), 23 deletions(-) diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt index 96e9ddf4e0..d268c664d7 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapper.kt @@ -106,12 +106,7 @@ private fun AfternoteDetailDto.toDraftContent(type: AfternoteType): DraftContent AfternoteType.MEMORIAL -> { DraftContent.Memorial( songs = playlist?.songs?.map { it.toDomain() }.orEmpty(), - media = - MemorialMedia( - photoUrl = playlist?.memorialPhotoUrl, - videoUrl = playlist?.memorialVideo?.videoUrl, - thumbnailUrl = playlist?.memorialVideo?.thumbnailUrl, - ), + media = playlist.toMemorialMedia(), ) } @@ -183,12 +178,18 @@ private fun AfternoteDetailDto.toTimestamps(): DetailTimestamps = private fun AfternotePlaylistDto.toMemorialContent() = DetailContent.Memorial( songs = songs.map { it.toDomain() }, - media = - MemorialMedia( - photoUrl = memorialPhotoUrl, - videoUrl = memorialVideo?.videoUrl, - thumbnailUrl = memorialVideo?.thumbnailUrl, - ), + media = toMemorialMedia(), + ) + +/** + * 미디어 URL 의 빈 값은 여기서 한 번만 `null` 로 맞춘다. 앱 안에서는 `null` 만 「없음」이다. + * BE `main` 은 빈 미디어를 `null` 로 저장하므로 빈 문자열은 옛 데이터에서만 온다. + */ +private fun AfternotePlaylistDto?.toMemorialMedia() = + MemorialMedia( + photoUrl = this?.memorialPhotoUrl?.ifBlank { null }, + videoUrl = this?.memorialVideo?.videoUrl?.ifBlank { null }, + thumbnailUrl = this?.memorialVideo?.thumbnailUrl?.ifBlank { null }, ) private fun AfternoteDetailReceiverDto.toDomain(): DetailReceiver? = diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt index f6f72c42ad..70dc4f1f72 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/AfternoteDetailMapperTest.kt @@ -19,7 +19,7 @@ import org.junit.Test * [AfternoteDetailDto.toDomain] 회귀 가드 (작성자 상세). * 핵심 경계: 공통 필드와 타입별 [DetailContent] 분리, 부분·부재 credentials 의 빈 값 강등, * receivers null→emptyList, receiver 필드 null→"", processingMethods null→emptyList, - * memorialVideo null→video/thumbnail null. + * memorialVideo null→video/thumbnail null, 미디어 URL 빈 문자열→null. */ class AfternoteDetailMapperTest { @Test @@ -231,6 +231,30 @@ class AfternoteDetailMapperTest { assertEquals("a", memorial.songs.single().artist) } + @Test + fun `toDomain - 미디어 URL 의 빈 문자열은 없음으로 맞춘다`() { + val result = + Json + .decodeFromString( + """ + { + "afternoteId":1,"category":"PLAYLIST","title":"t","isDraft":false, + "updatedAt":"$UPDATED_AT","receivers":[], + "playlist":{ + "memorialPhotoUrl":"", + "songs":[], + "memorialVideo":{"videoUrl":" ","thumbnailUrl":""} + } + } + """.trimIndent(), + ).toDomain() + + val media = (result.content as DetailContent.Memorial).media + assertNull(media.photoUrl) + assertNull(media.videoUrl) + assertNull(media.thumbnailUrl) + } + @Test fun `toDomain - playlist의 공통 leaveMessage도 보존한다`() { val result = diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt index 99997928a7..1bb1f767b3 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorFormMapper.kt @@ -119,7 +119,7 @@ internal object AfternoteEditorFormMapper { memorialThumbnailUrl: String? = null, ): MemorialWritePayload = MemorialWritePayload( - memorialPhotoUrl = memorialPhotoUrl?.ifBlank { null }, + memorialPhotoUrl = memorialPhotoUrl, songs = playlistSongs.map { it.toMemorialSongPayload() }, memorialVideo = memorialVideoPayload(videoUrl = memorialVideoUrl, thumbnailUrl = memorialThumbnailUrl), ) @@ -368,7 +368,7 @@ internal object AfternoteEditorFormMapper { MemorialSnapshot( title = title, leaveMessageBlocks = leaveMessageBlocks, - photoUrl = content.media.photoUrl?.ifBlank { null }, + photoUrl = content.media.photoUrl, video = content.media.toVideoPayload(), songs = content.songs.map { song -> @@ -428,7 +428,7 @@ internal object AfternoteEditorFormMapper { MemorialSnapshot( title = title, leaveMessageBlocks = leaveMessageBlocks, - photoUrl = memorialMediaUrls.memorialPhotoUrl?.ifBlank { null }, + photoUrl = memorialMediaUrls.memorialPhotoUrl, video = memorialMediaUrls.toVideoPayload(), songs = playlistSongs.map { it.toMemorialSongPayload() }, ) @@ -455,14 +455,14 @@ internal object AfternoteEditorFormMapper { private fun MemorialMediaUrls.toVideoPayload(): MemorialVideoPayload? = memorialVideoPayload(videoUrl = memorialVideoUrl, thumbnailUrl = memorialThumbnailUrl) - /** 영상 주소가 비었으면 영상 슬롯 자체가 없다. 썸네일만 남은 값은 싣지 않는다. */ + /** + * 영상 주소가 없으면 영상 슬롯 자체가 없다. 썸네일만 남은 값은 싣지 않는다. + * 빈 문자열은 입구(상세 data 매퍼, 폼의 `MediaInput` 판정과 영상 첨부)에서 이미 `null` 로 맞춰져 들어온다. + */ private fun memorialVideoPayload( videoUrl: String?, thumbnailUrl: String?, - ): MemorialVideoPayload? = - videoUrl?.ifBlank { null }?.let { url -> - MemorialVideoPayload(videoUrl = url, thumbnailUrl = thumbnailUrl?.ifBlank { null }) - } + ): MemorialVideoPayload? = videoUrl?.let { url -> MemorialVideoPayload(videoUrl = url, thumbnailUrl = thumbnailUrl) } } /** diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideo.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideo.kt index 1097c2836a..9c7cbfcba1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideo.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideo.kt @@ -59,12 +59,12 @@ internal sealed class EditableMemorialVideo { /** 새 선택으로 교체한다. 이전 선택의 썸네일은 물려주지 않는다. 빈 문자열은 [MemorialVideoAttachment.ofOrNull] 규칙대로 첨부 없음이다. */ internal fun withSelection(url: String): EditableMemorialVideo = MemorialVideoAttachment.ofOrNull(url)?.let(::PendingUpload) ?: NoVideo - /** 미저장 선택에서 파생된 썸네일만 갱신한다. 선택이 사라졌다면 늦은 결과를 버린다. */ + /** 미저장 선택에서 파생된 썸네일만 갱신한다. 선택이 사라졌다면 늦은 결과를 버린다. 빈 문자열은 썸네일 없음이다. */ internal fun withSelectionThumbnail(url: String): EditableMemorialVideo = when (this) { NoVideo -> this is Uploaded -> this - is PendingUpload -> PendingUpload(video.copy(thumbnailUrl = url)) + is PendingUpload -> PendingUpload(video.copy(thumbnailUrl = url.ifBlank { null })) } /** diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt index 25387a4038..df8af4a72b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/state/EditableMemorialVideoTest.kt @@ -125,6 +125,17 @@ class EditableMemorialVideoTest { assertEquals(serverVideo, afterLateThumbnail.displayed) } + @Test + fun `빈 썸네일 결과는 썸네일 없음이다`() { + val video = + EditableMemorialVideo + .empty() + .withSelection(pickedVideo.url) + .withSelectionThumbnail("") + + assertEquals(MemorialVideoAttachment(url = pickedVideo.url, thumbnailUrl = null), video.displayed) + } + @Test fun `썸네일을 떼도 출처는 남는다`() { assertEquals( From c3b5bf195bfd68b5ebafdb8fff9b56c1b6bafd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:14:43 +0900 Subject: [PATCH 033/116] =?UTF-8?q?fix(build):=20freemarker=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=ED=95=98=ED=95=9C=EC=9D=84=202.3.35=20=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B0=EB=8B=A4=20(#2204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 루트 buildEnvironment 의 build-logic → kover-gradle-plugin 0.9.9 → kover-features-jvm → intellij-coverage-reporter 1.0.765 → coverage-report 1.0.25 경로가 org.freemarker:freemarker 2.3.32 를 끌어와 GHSA-27j2-h3m2-8237(critical, 2.3.35 에서 패치)에 걸린다. kover 최신 0.9.9 와 coverage-report 최신 1.0.27 도 2.3.32 를 고정해 상류 업그레이드로는 풀리지 않는다. #2132 와 같은 방식으로 카탈로그에 freemarker = "2.3.35" 를 두고 build-logic 의 constraints 에 하한을 건다. included build 의 constraint 가 루트 classpath 해석에도 실려 buildEnvironment 에서 2.3.32 -> 2.3.35 로 바뀌는 것을 확인해, 루트 buildscript 에는 리터럴 하한을 따로 두지 않았다. --- build-logic/build.gradle.kts | 7 ++++++- gradle/libs.versions.toml | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 938128259b..bff7ab7e97 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -28,7 +28,9 @@ dependencies { testImplementation(gradleTestKit()) // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#2132·#981· - // #982·#985). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). + // #982·#985·#2204). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). + // freemarker 는 거꾸로 이 빌드의 kover-gradle-plugin 만 끌어온다. 이 constraint 는 included build 의 + // 변형에 실려 루트 buildEnvironment 에서도 2.3.32 -> 2.3.35 로 해석되므로 루트에 리터럴을 두지 않는다. constraints { listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> implementation("org.bouncycastle:$artifact:${libs.versions.bouncycastle.get()}") { @@ -44,6 +46,9 @@ dependencies { implementation("org.jdom:jdom2:${libs.versions.jdom2.get()}") { because("GHSA-2363-cqg2-863c — 2.0.6.1 미만 취약 — #985") } + implementation("org.freemarker:freemarker:${libs.versions.freemarker.get()}") { + because("GHSA-27j2-h3m2-8237 — 2.3.35 미만 취약, kover 0.9.9 경유 2.3.32 잔존 — #2204") + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c270032fe7..b1b5a2ccde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,10 @@ httpclient = "4.5.14" logback = "1.5.34" testcontainers = "2.0.5" protobufJavalite = "3.25.9" +# 빌드 클래스패스 전용 보안 하한 — GHSA-27j2-h3m2-8237(critical)이 2.3.35 에서 패치(#2204). kover 0.9.9 가 +# kover-features-jvm → intellij-coverage-reporter → coverage-report 1.0.25 경유로 2.3.32 를 고정하고, kover· +# coverage-report 최신판도 같은 값이라 상류 업그레이드로는 못 푼다. build-logic 의 constraint 가 쓴다. +freemarker = "2.3.35" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } From 7db878c5511341d8c91d3220455e072c217aeeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:14:35 +0900 Subject: [PATCH 034/116] =?UTF-8?q?build(receiver):=20receiver=20presentat?= =?UTF-8?q?ion=20=EC=9D=B4=20=EC=93=B0=EC=A7=80=20=EC=95=8A=EB=8A=94=20fea?= =?UTF-8?q?ture:afternote:domain=20=EC=9D=98=EC=A1=B4=EC=9D=84=20=EB=B9=BC?= =?UTF-8?q?=EA=B3=A0=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=EB=A1=9C=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20(#2?= =?UTF-8?q?174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신 애프터노트 화면(서비스명 아이콘 매핑 포함)은 2c6da444b 가 afternote 모듈로 되돌렸고, 마지막 import(ReceiverHomeViewModel 의 AfternoteType)는 9f893a9f3(#1462)가 수신자 홈을 feature:home 으로 옮기며 가져갔다. 그 뒤로 src 의 main·test·screenshotTest 에 남은 참조는 recordsbox KDoc 의 패키지 링크 [com.afternote.feature.afternote.domain] 한 곳뿐이다. verify-kdoc-links.mjs 는 모듈 classpath 가 아니라 저장소 전체 패키지 집합으로 링크를 풀어 의존을 빼도 이 링크는 그대로 해석된다. 이 모듈이 쓰는 afternote presentation 의 reporting API 시그니처에도 afternote domain 타입이 없다. 위 주석의 두 사유(수신자 홈의 Repository·모델 조합, 도메인 모델·서비스명 아이콘 매핑)는 위 두 이동 뒤로 이 모듈에 없고, 재검토 조건이던 #615 는 닫혔다. 남은 afternote presentation 의존의 실제 사유인 실패 리포팅 키 사용으로 바꿨다. --- feature/receiver/presentation/build.gradle.kts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/feature/receiver/presentation/build.gradle.kts b/feature/receiver/presentation/build.gradle.kts index 1e571a5ee8..6a4cf6e3dc 100644 --- a/feature/receiver/presentation/build.gradle.kts +++ b/feature/receiver/presentation/build.gradle.kts @@ -16,9 +16,7 @@ android { dependencies { implementation(projects.feature.receiver.domain) - // 수신자 홈이 각 기능의 수신 Repository·모델을 조합한다. afternote 의존(도메인 모델·서비스명 - // 아이콘 매핑)은 수신자 흐름 잔여분이 afternote 에서 이 모듈로 합류(#615)하면 재검토. - implementation(projects.feature.afternote.domain) + // 수신자 흐름의 실패를 애프터노트 리포팅 키(AfternoteFailureStage·recordAfternoteFailure)로 기록한다. implementation(projects.feature.afternote.presentation) implementation(projects.feature.mindrecord.domain) implementation(projects.feature.timeletter.domain) From 1833f7aac6e8c387b46b88edd2c3092dcafe5791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:22:32 +0900 Subject: [PATCH 035/116] =?UTF-8?q?docs(build):=20freemarker=20=ED=95=98?= =?UTF-8?q?=ED=95=9C=20=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=EB=A3=A8=ED=8A=B8=20?= =?UTF-8?q?=ED=95=98=ED=95=9C=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=96=BC=EC=96=B4=20=EC=A0=81=EB=8A=94=EB=8B=A4=20(#2204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-logic constraints 머리 주석의 「같은 근거(...)」 목록은 루트 build.gradle.kts 에 이미 있는 하한을 별도 빌드인 여기서 다시 거는 이슈들이다. freemarker 는 루트에 하한이 없고 AGP 가 아니라 이 빌드의 kover-gradle-plugin 만 끌어오므로, 그 목록에 #2204 를 넣으면 다음 줄의 설명과 어긋난다. #2204 는 freemarker 를 설명하는 문장으로 옮긴다. --- build-logic/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index bff7ab7e97..e443180779 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -28,9 +28,9 @@ dependencies { testImplementation(gradleTestKit()) // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#2132·#981· - // #982·#985·#2204). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). - // freemarker 는 거꾸로 이 빌드의 kover-gradle-plugin 만 끌어온다. 이 constraint 는 included build 의 - // 변형에 실려 루트 buildEnvironment 에서도 2.3.32 -> 2.3.35 로 해석되므로 루트에 리터럴을 두지 않는다. + // #982·#985). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). + // freemarker(#2204)는 거꾸로 이 빌드의 kover-gradle-plugin 만 끌어온다. 이 constraint 는 included + // build 의 변형에 실려 루트 buildEnvironment 에서도 2.3.32 -> 2.3.35 로 해석되므로 루트에 리터럴을 두지 않는다. constraints { listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> implementation("org.bouncycastle:$artifact:${libs.versions.bouncycastle.get()}") { From 6207255830dc88c7c12e4013b2eeaf0244a49444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:42:45 +0900 Subject: [PATCH 036/116] =?UTF-8?q?refactor(setting):=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=C2=B7=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=EC=9D=84=20MVI=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=8B=A0=ED=98=B8=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=AC=EC=9C=A0=EC=9E=85=EC=9D=84=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20(#1502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConnectedAccountsViewModel 과 PushNotificationViewModel 을 MviViewModel 로 전환한다. 화면 입력은 onIntent 하나로 모으고, 비동기 결과는 dispatch 로 내며 전이는 reduce 에서만 한다. - 연결 계정: Channel 을 걷고 연결 요청을 UiState.pendingLinkProvider 신호로 둔다. 소비는 ConsumeLinkRequest(provider) 로 받아, 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않는다. 카카오·구글 인증과 실패 스낵바는 화면 코루틴에서 잠금으로 하나씩 처리해 예전 이벤트 수집의 순서를 지킨다. - 알림 설정: MutableSharedFlow 를 걷고 마케팅 동의 저장 실패를 isMarketingConsentSaveFailed 신호로 둔다. 화면이 STARTED 인 동안만 신호를 올리도록 MarketingFeedbackStarted·Stopped Intent 를 두어, 화면이 없는 동안의 실패를 재진입에 재생하지 않는 #558 계약을 보존한다. 값 롤백과 ErrorReporter 진단 단계(sms·email·push_consent_update)는 그대로다. - 푸시 토글 재시도 대상(failedUpdate)을 VM 필드에서 UiState 로 옮겨 실패 안내와 같은 전이에서 채우고 비운다. 저장 중 중복 제출 가드와 성공·실패·재시도·취소 동작은 그대로다. - UiState 가 내부 타입을 품게 되어 PushNotificationUiState 와 그 상태를 받는 두 컴포넌트를 모듈 안으로 좁힌다. 알림 설정 화면은 Screen/Content 2단으로 나눈다. - konsist 규칙 D: 아직 전환하지 않은 feature presentation ViewModel 도 이벤트용 Channel·MutableSharedFlow 를 새로 선언하지 못하게 한다. 기존 선언은 LEGACY_EVENT_STREAMS 기준선(ReceiverRegisterViewModel._events)으로 인정한다. 두 VM 은 PENDING_MVI_MIGRATION 에서 뺀다. ReceiverRegisterViewModel 은 교체 대상(receiver-register-post)이라 #944 전환이 가져간다. 폼 값과 이메일 등록 버튼이 PM 결정 대기다. --- docs/convention/mvi.md | 11 +- .../account/ConnectedAccountsContract.kt | 48 +++ .../account/ConnectedAccountsScreen.kt | 49 +-- .../account/ConnectedAccountsUiState.kt | 22 +- .../account/ConnectedAccountsViewModel.kt | 88 ++++-- .../notification/NotificationSettingScreen.kt | 94 ++++-- .../notification/PushNotificationContract.kt | 95 ++++++ .../notification/PushNotificationScreen.kt | 19 +- .../notification/PushNotificationUiState.kt | 31 +- .../notification/PushNotificationViewModel.kt | 291 ++++++++++-------- .../component/DeviceAlarmOffSection.kt | 2 +- .../component/PushToggleSection.kt | 2 +- .../SettingAccountSecurityTest.kt | 18 +- .../presentation/SettingCompletionTest.kt | 20 +- .../SettingCoroutineCancellationTest.kt | 18 +- .../setting/presentation/SettingFlowTest.kt | 4 +- .../account/ConnectedAccountsMviTest.kt | 149 +++++++++ .../account/ConnectedAccountsSignalTest.kt | 46 +++ .../MarketingConsentFailureTest.kt | 125 +++++--- .../NotificationSettingSignalTest.kt | 117 +++++++ .../PushNotificationSaveReportingTest.kt | 24 +- .../PushNotificationViewModelTest.kt | 92 +++++- .../konsist/MviContractKonsistTest.kt | 131 +++++++- 23 files changed, 1156 insertions(+), 340 deletions(-) create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt diff --git a/docs/convention/mvi.md b/docs/convention/mvi.md index 7e6209284e..0587d23f04 100644 --- a/docs/convention/mvi.md +++ b/docs/convention/mvi.md @@ -171,27 +171,30 @@ ViewModel 테스트는 작업 결과 신호와 소비 후 초기화를, 화면 ## 강제 -`MviContractKonsistTest` 가 셋을 본다. +`MviContractKonsistTest` 가 넷을 본다. | 규칙 | 내용 | | --- | --- | | A | `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 선언하지 않는다 | | B | `feature/*/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다 | | C | `MviIntent`·`ReducerEvent` 를 직접 구현하는 타입은 `sealed interface` 다 | +| D | 아직 전환하지 않은 `feature/*/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 새로 선언하지 않는다 (#1502) | 규칙 B 는 아직 전환하지 않은 ViewModel 을 `PENDING_MVI_MIGRATION` 예외로 둔다(가드 도입 시점 49개, onboarding 파일럿 이후 46개). 모듈 전환 이슈가 닫힐 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 2개는 규칙 B 의 대상이 아니다 — #1809 가 처리한다. -세 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 +규칙 D 는 규칙 B 의 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘리지 못하게 한다. 규칙 A 는 전환한 ViewModel 만 보기 때문이다. 가드를 세울 때 이미 있던 선언은 `LEGACY_EVENT_STREAMS` 기준선으로 인정하고, 그 ViewModel 이 전환되거나 지워지면 기준선에서도 뺀다. 전환 전 상태 홀더인 `MutableStateFlow` 는 대상이 아니다. + +네 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 규칙 A 의 대상이 아니다 — 더블은 계약을 지키는 대상이 아니라 계약을 흉내 내는 도구다. -규칙 A·B 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 +규칙 A·B·D 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의 `parents(indirectParents = true)` 로는 안 되고(0.17.3 에서 이 스코프 구성으로는 직계와 같은 목록을 돌려준다 — 실측), 스캔한 파일에서 이름 색인을 만들어 직접 걷는다. ### 가드가 «안» 보는 것 - **`onIntent` 밖의 public 진입점.** 상속체가 `fun refreshOnReturn()` 같은 public 함수를 노출해도 - 세 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 + 네 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은 규약이지 CI 계약이 아니다. 리뷰에서 본다. - **`reduce` 밖의 전이.** 규칙 A 는 «상태 홀더 선언» 을 막을 뿐, `dispatch` 를 거치지 않는 다른 경로를 전수로 잡지는 못한다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt new file mode 100644 index 0000000000..54ccab2404 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ConnectedAccountsIntent : MviIntent { + data class Toggle( + val provider: String, + val enabled: Boolean, + ) : ConnectedAccountsIntent + + /** 화면이 플랫폼 인증으로 받아낸 [accessToken] 으로 [provider] 를 연결한다. */ + data class Link( + val provider: String, + val accessToken: String, + ) : ConnectedAccountsIntent + + /** 화면이 처리한 연결 요청 [provider] 를 되돌려준다. 그사이 다른 제공자 요청이 올라왔으면 그 요청은 남는다. */ + data class ConsumeLinkRequest( + val provider: String, + ) : ConnectedAccountsIntent +} + +internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent { + data class Loaded( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class LoadFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class AccountsChanged( + val accounts: List, + ) : ConnectedAccountsReducerEvent + + data class ChangeFailed( + val message: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequested( + val provider: String, + ) : ConnectedAccountsReducerEvent + + data class LinkRequestConsumed( + val provider: String, + ) : ConnectedAccountsReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 93774de3f9..784e1d093a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -3,9 +3,9 @@ package com.afternote.feature.setting.presentation.account import android.app.Activity import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -14,12 +14,16 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.feature.setting.presentation.BuildConfig import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken import com.afternote.feature.setting.presentation.account.social.requestKakaoAccessToken import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Composable internal fun ConnectedAccountsScreen( @@ -33,16 +37,20 @@ internal fun ConnectedAccountsScreen( val snackbarHostState = remember { SnackbarHostState() } val kakaoAccountLinkFailedMessage = stringResource(R.string.setting_kakao_account_link_failed) val googleAccountLinkFailedMessage = stringResource(R.string.setting_google_account_link_failed) + // 소비가 ObserveSignal 의 effect 를 다시 시작시켜도 인증·스낵바는 끝까지 간다. 예전 이벤트 수집처럼 + // 요청을 하나씩 처리하도록 화면 코루틴에서 잠금으로 줄을 세운다. + val linkScope = rememberCoroutineScope() + val linkMutex = remember { Mutex() } - LaunchedEffect(Unit) { - viewModel.events.collect { event -> - when (event) { - is ConnectedAccountsEvent.ShowError -> { - snackbarHostState.showSnackbar(event.message) - } - - is ConnectedAccountsEvent.RequestLink -> { - when (event.provider) { + uiState.pendingLinkProvider?.let { pendingProvider -> + ObserveSignal( + signal = pendingProvider, + consumed = ConnectedAccountsIntent.ConsumeLinkRequest(pendingProvider), + onIntent = viewModel::onIntent, + ) { provider -> + linkScope.launch { + linkMutex.withLock { + when (provider) { "kakao" -> { val activity = context.findActivity() val authResult = @@ -51,15 +59,13 @@ internal fun ConnectedAccountsScreen( ?: KakaoAuthResult.Failure when (authResult) { is KakaoAuthResult.Success -> { - viewModel.link("kakao", authResult.accessToken) + viewModel.onIntent(ConnectedAccountsIntent.Link("kakao", authResult.accessToken)) } KakaoAuthResult.Cancelled -> {} KakaoAuthResult.Failure -> { - snackbarHostState.showSnackbar( - kakaoAccountLinkFailedMessage, - ) + snackbarHostState.showSnackbar(kakaoAccountLinkFailedMessage) } } } @@ -69,14 +75,13 @@ internal fun ConnectedAccountsScreen( context = context, credentialManager = credentialManager, serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, - ).onSuccess { token -> viewModel.link("google", token) } - .onFailure { e -> - if (e !is CoreAuthFailure.UserCancelledAuth) { - viewModel.notifyLinkError( - googleAccountLinkFailedMessage, - ) - } + ).onSuccess { token -> + viewModel.onIntent(ConnectedAccountsIntent.Link("google", token)) + }.onFailure { e -> + if (e !is CoreAuthFailure.UserCancelledAuth) { + snackbarHostState.showSnackbar(googleAccountLinkFailedMessage) } + } } } } @@ -88,7 +93,7 @@ internal fun ConnectedAccountsScreen( uiState = uiState, snackbarHostState = snackbarHostState, onBack = onBack, - onToggle = viewModel::onToggle, + onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) }, modifier = modifier, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index 7fb4ecbf53..e17aaa182f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -1,17 +1,17 @@ package com.afternote.feature.setting.presentation.account +import com.afternote.core.ui.mvi.UiState + data class ConnectedAccountsUiState( val isLoading: Boolean = false, val accounts: List = emptyList(), val errorMessage: String? = null, -) - -sealed interface ConnectedAccountsEvent { - data class RequestLink( - val provider: String, - ) : ConnectedAccountsEvent - - data class ShowError( - val message: String, - ) : ConnectedAccountsEvent -} + /** + * 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502). + * + * ViewModel 은 Activity·Context 에 닿지 않으므로 토큰을 직접 받지 못한다. 화면이 이 신호를 받아 + * 인증을 돌린 뒤 토큰만 [ConnectedAccountsIntent.Link] 로 돌려보내고, 받은 즉시 + * [ConnectedAccountsIntent.ConsumeLinkRequest] 로 되돌린다. + */ + val pendingLinkProvider: String? = null, +) : UiState diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 88d50b1263..92a53c2e3c 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -1,17 +1,12 @@ package com.afternote.feature.setting.presentation.account -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,59 +15,100 @@ internal class ConnectedAccountsViewModel @Inject constructor( private val accountRepository: SettingAccountRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(ConnectedAccountsUiState(isLoading = true)) - val uiState = _uiState.asStateFlow() - - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() - + ) : MviViewModel( + ConnectedAccountsUiState(isLoading = true), + ) { init { loadConnectedAccounts() } + override fun onIntent(intent: ConnectedAccountsIntent) { + when (intent) { + is ConnectedAccountsIntent.Toggle -> { + toggle(intent.provider, intent.enabled) + } + + is ConnectedAccountsIntent.Link -> { + link(intent.provider, intent.accessToken) + } + + is ConnectedAccountsIntent.ConsumeLinkRequest -> { + dispatch( + ConnectedAccountsReducerEvent.LinkRequestConsumed(intent.provider), + ) + } + } + } + + override fun reduce( + state: ConnectedAccountsUiState, + event: ConnectedAccountsReducerEvent, + ): ConnectedAccountsUiState = + when (event) { + is ConnectedAccountsReducerEvent.Loaded -> { + state.copy(isLoading = false, accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.LoadFailed -> { + state.copy(isLoading = false, errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.AccountsChanged -> { + state.copy(accounts = event.accounts) + } + + is ConnectedAccountsReducerEvent.ChangeFailed -> { + state.copy(errorMessage = event.message) + } + + is ConnectedAccountsReducerEvent.LinkRequested -> { + state.copy(pendingLinkProvider = event.provider) + } + + // 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않게 한다. + is ConnectedAccountsReducerEvent.LinkRequestConsumed -> { + if (state.pendingLinkProvider == event.provider) state.copy(pendingLinkProvider = null) else state + } + } + private fun loadConnectedAccounts() { viewModelScope.launch { runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> - _uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) } + dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) }.onFailure { - _uiState.update { it.copy(isLoading = false, errorMessage = "계정 정보를 불러올 수 없습니다.") } + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) } } } - fun onToggle( + private fun toggle( provider: String, enabled: Boolean, ) { if (enabled) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.RequestLink(provider)) } + dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider)) } else { unlink(provider) } } - fun notifyLinkError(message: String) { - viewModelScope.launch { _events.send(ConnectedAccountsEvent.ShowError(message)) } - } - - fun link( + private fun link( provider: String, accessToken: String, ) { viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결에 실패했습니다.")) } } } private fun unlink(provider: String) { viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } - .onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } } - .onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } } + .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } + .onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결 해제에 실패했습니다.")) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index 011c180321..df4342536b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -22,9 +22,9 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -36,15 +36,16 @@ import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.repeatOnLifecycle import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveFlag import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.launch @Composable internal fun NotificationSettingScreen( @@ -56,7 +57,6 @@ internal fun NotificationSettingScreen( val context = LocalContext.current val activity = remember(context) { context.findActivity() } val snackbarHostState = remember { SnackbarHostState() } - val lifecycleOwner = LocalLifecycleOwner.current val marketingConsentSaveFailedMessage = stringResource(R.string.setting_marketing_consent_save_failed) val openNotificationSettings = { val intent = @@ -67,7 +67,7 @@ internal fun NotificationSettingScreen( } val notificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) if ( !granted && activity != null && @@ -81,21 +81,61 @@ internal fun NotificationSettingScreen( } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshDeviceAlarmStatus() + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) } - LaunchedEffect(viewModel, lifecycleOwner, marketingConsentSaveFailedMessage) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.events.collect { event -> - when (event) { - PushNotificationEvent.MarketingConsentSaveFailed -> { - snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) - } + // 화면이 STARTED 인 동안만 마케팅 동의 저장 실패를 안내한다. 화면이 없는 동안의 실패는 다음 진입에 재생하지 않는다 (#558). + LifecycleStartEffect(viewModel) { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + onStopOrDispose { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) } + } + + // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. + val snackbarScope = rememberCoroutineScope() + ObserveFlag( + raised = uiState.isMarketingConsentSaveFailed, + consumed = PushNotificationIntent.ConsumeMarketingConsentSaveFailure, + onIntent = viewModel::onIntent, + ) { + snackbarScope.launch { snackbarHostState.showSnackbar(marketingConsentSaveFailedMessage) } + } + + NotificationSettingContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onBack = onBack, + onPushNotificationClick = onPushNotificationClick, + onDeviceAlarmClick = { + val permissionGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { + NotificationPermissionAction.RequestPermission -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + NotificationPermissionAction.OpenSettings -> { + openNotificationSettings() } } - } - } + }, + onMarketingConsentChange = { consent, checked -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, checked)) + }, + ) +} +@Composable +private fun NotificationSettingContent( + uiState: PushNotificationUiState, + snackbarHostState: SnackbarHostState, + onBack: () -> Unit, + onPushNotificationClick: () -> Unit, + onDeviceAlarmClick: () -> Unit, + onMarketingConsentChange: (MarketingConsent, Boolean) -> Unit, +) { Scaffold( topBar = { DetailTopBar( @@ -117,22 +157,8 @@ internal fun NotificationSettingScreen( modifier = Modifier .fillMaxWidth() - .clickable { - val permissionGranted = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - when (notificationPermissionAction(Build.VERSION.SDK_INT, permissionGranted)) { - NotificationPermissionAction.RequestPermission -> { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - NotificationPermissionAction.OpenSettings -> { - openNotificationSettings() - } - } - }.padding(vertical = 12.dp), + .clickable(onClick = onDeviceAlarmClick) + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -160,9 +186,9 @@ internal fun NotificationSettingScreen( } else { DeviceAlarmOffSection( uiState = uiState, - onSmsCheck = viewModel::onSmsChecked, - onEmailCheck = viewModel::onEmailChecked, - onPushCheck = viewModel::onPushChecked, + onSmsCheck = { onMarketingConsentChange(MarketingConsent.SMS, it) }, + onEmailCheck = { onMarketingConsentChange(MarketingConsent.EMAIL, it) }, + onPushCheck = { onMarketingConsentChange(MarketingConsent.PUSH, it) }, ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt new file mode 100644 index 0000000000..0ac593a7a6 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -0,0 +1,95 @@ +package com.afternote.feature.setting.presentation.notification + +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface PushNotificationIntent : MviIntent { + /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ + data object RefreshDeviceAlarmStatus : PushNotificationIntent + + data class ChangeMarketingConsent( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationIntent + + data class TogglePushSetting( + val setting: PushSetting, + val on: Boolean, + ) : PushNotificationIntent + + data object RetrySave : PushNotificationIntent + + data object DismissSaveFailure : PushNotificationIntent + + /** + * 알림 설정 화면이 STARTED 에 들어섰다. 이때부터 마케팅 동의 저장 실패를 안내 신호로 올린다. + * + * STARTED 밖에서 난 실패는 신호로 남기지 않고, [MarketingFeedbackStopped] 가 남은 신호도 걷는다. + * 화면이 없는 동안의 안내를 다음 진입에 재생하지 않는 #558 계약이다. + */ + data object MarketingFeedbackStarted : PushNotificationIntent + + data object MarketingFeedbackStopped : PushNotificationIntent + + data object ConsumeMarketingConsentSaveFailure : PushNotificationIntent +} + +internal sealed interface PushNotificationReducerEvent : ReducerEvent { + data class DeviceAlarmStatusRead( + val on: Boolean, + ) : PushNotificationReducerEvent + + data object PushSettingsLoading : PushNotificationReducerEvent + + data class PushSettingsLoaded( + val setting: UserPushSetting, + ) : PushNotificationReducerEvent + + data object PushSettingsLoadFailed : PushNotificationReducerEvent + + data class MarketingConsentsLoaded( + val consent: UserMarketingConsent, + ) : PushNotificationReducerEvent + + data class MarketingConsentChanged( + val consent: MarketingConsent, + val checked: Boolean, + ) : PushNotificationReducerEvent + + /** [requested] 로 바꾸려던 저장이 실패했다. 값은 그 반대로 되돌린다. */ + data class MarketingConsentSaveFailed( + val consent: MarketingConsent, + val requested: Boolean, + ) : PushNotificationReducerEvent + + data class PushSettingSaving( + val update: PushSettingUpdate, + ) : PushNotificationReducerEvent + + data class PushSettingSaved( + val setting: PushSetting, + ) : PushNotificationReducerEvent + + data class PushSettingSaveFailed( + val update: PushSettingUpdate, + val previous: Boolean, + val failure: PushNotificationSaveFailure, + ) : PushNotificationReducerEvent + + data object SaveFailureDismissed : PushNotificationReducerEvent + + data class MarketingFeedbackActiveChanged( + val active: Boolean, + ) : PushNotificationReducerEvent + + data object MarketingConsentSaveFailureConsumed : PushNotificationReducerEvent +} + +/** 기기 알림이 꺼졌을 때 받는 마케팅 알림 채널. */ +internal enum class MarketingConsent { + SMS, + EMAIL, + PUSH, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index ea13176186..ae9c1ec4e2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -6,11 +6,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.NetworkErrorPopup import com.afternote.core.ui.popup.ServerErrorPopup -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar -import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection -import com.afternote.feature.setting.presentation.notification.component.PushToggleSection @Composable internal fun PushNotificationScreen( @@ -22,23 +17,23 @@ internal fun PushNotificationScreen( PushNotificationContent( uiState = uiState, onBack = onBack, - onNewsletterToggle = viewModel::onNewsletterToggle, - onMindRecordToggle = viewModel::onMindRecordToggle, - onAfternoteToggle = viewModel::onAfternoteToggle, + onNewsletterToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, it)) }, + onMindRecordToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, it)) }, + onAfternoteToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, it)) }, ) when (uiState.saveFailure) { PushNotificationSaveFailure.NETWORK -> { NetworkErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } PushNotificationSaveFailure.SERVER -> { ServerErrorPopup( - onRetry = viewModel::onSaveFailureRetry, - onDismiss = viewModel::onSaveFailureDismiss, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetrySave) }, + onDismiss = { viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) }, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 81ee23a7c7..adcf6fd7a7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -1,6 +1,8 @@ package com.afternote.feature.setting.presentation.notification -data class PushNotificationUiState( +import com.afternote.core.ui.mvi.UiState + +internal data class PushNotificationUiState( val isLoading: Boolean = false, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) @@ -15,13 +17,34 @@ data class PushNotificationUiState( val isMindRecordUpdating: Boolean = false, val isAfternoteUpdating: Boolean = false, val saveFailure: PushNotificationSaveFailure? = null, -) + /** + * [saveFailure] 에서 재시도할 변경. [saveFailure] 와 같은 전이에서 함께 채우고 함께 비운다. + * + * 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. + * 이미 실패한 토글은 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, + * 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 + * 없어 의도적으로 단순화했다 (#558 리뷰 합의). + */ + val failedUpdate: PushSettingUpdate? = null, + /** 알림 설정 화면이 STARTED 인 동안만 true 다. 이때만 마케팅 동의 저장 실패를 안내 신호로 올린다 (#558). */ + val isMarketingFeedbackActive: Boolean = false, + /** 마케팅 동의 저장 실패 안내 신호. 화면이 스낵바를 띄우고 소비 Intent 로 내린다 (#1502). */ + val isMarketingConsentSaveFailed: Boolean = false, +) : UiState enum class PushNotificationSaveFailure { NETWORK, SERVER, } -sealed interface PushNotificationEvent { - data object MarketingConsentSaveFailed : PushNotificationEvent +/** 기기 알림이 켜졌을 때 받는 서비스 알림 종류. */ +internal enum class PushSetting { + NEWSLETTER, + MIND_RECORD, + AFTERNOTE, } + +internal data class PushSettingUpdate( + val setting: PushSetting, + val on: Boolean, +) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index 6243823ec5..da43aa7fcd 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -3,22 +3,15 @@ package com.afternote.feature.setting.presentation.notification import android.content.Context import android.util.Log import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingNotificationRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -28,56 +21,144 @@ internal class PushNotificationViewModel @ApplicationContext private val context: Context, private val notificationRepository: SettingNotificationRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(PushNotificationUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // 화면이 없는 동안의 안내는 다음 진입에 재생하지 않는다. - private val _events = - MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val events = _events.asSharedFlow() - - // 슬롯 하나 — 서로 다른 토글이 연달아 실패해도 재시도 대상은 마지막 실패만 남긴다. - // 이미 실패한 토글은 위에서 이전 값으로 롤백되어 화면에 "안 켜짐"으로 보이므로, - // 조용히 사라지는 것은 재시도 "대상"뿐이다. 여러 실패를 동시에 재시도하는 요구가 - // 없어 의도적으로 단순화했다 (#558 리뷰 합의). - private var failedUpdate: PushSettingUpdate? = null - + ) : MviViewModel(PushNotificationUiState()) { init { refreshDeviceAlarmStatus() loadPushSettings() loadMarketingConsents() } - fun refreshDeviceAlarmStatus() { + override fun onIntent(intent: PushNotificationIntent) { + when (intent) { + PushNotificationIntent.RefreshDeviceAlarmStatus -> { + refreshDeviceAlarmStatus() + } + + is PushNotificationIntent.ChangeMarketingConsent -> { + updateMarketingConsent(intent.consent, intent.checked) + } + + is PushNotificationIntent.TogglePushSetting -> { + updatePushSetting(PushSettingUpdate(intent.setting, intent.on)) + } + + PushNotificationIntent.RetrySave -> { + retrySave() + } + + PushNotificationIntent.DismissSaveFailure -> { + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) + } + + PushNotificationIntent.MarketingFeedbackStarted -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = true)) + } + + PushNotificationIntent.MarketingFeedbackStopped -> { + dispatch(PushNotificationReducerEvent.MarketingFeedbackActiveChanged(active = false)) + } + + PushNotificationIntent.ConsumeMarketingConsentSaveFailure -> { + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed) + } + } + } + + override fun reduce( + state: PushNotificationUiState, + event: PushNotificationReducerEvent, + ): PushNotificationUiState = + when (event) { + is PushNotificationReducerEvent.DeviceAlarmStatusRead -> { + state.copy(isDeviceAlarmOn = event.on) + } + + PushNotificationReducerEvent.PushSettingsLoading -> { + state.copy(isLoading = true) + } + + is PushNotificationReducerEvent.PushSettingsLoaded -> { + state.copy( + isLoading = false, + isNewsletterOn = event.setting.timeLetter, + isMindRecordOn = event.setting.mindRecord, + isAfternoteOn = event.setting.afterNote, + ) + } + + PushNotificationReducerEvent.PushSettingsLoadFailed -> { + state.copy(isLoading = false) + } + + is PushNotificationReducerEvent.MarketingConsentsLoaded -> { + state.copy( + isSmsChecked = event.consent.sms, + isEmailChecked = event.consent.email, + isPushChecked = event.consent.push, + ) + } + + is PushNotificationReducerEvent.MarketingConsentChanged -> { + state.withMarketingConsent(event.consent, event.checked) + } + + // 화면이 STARTED 밖이면 값만 되돌리고 안내 신호는 올리지 않는다 (#558). + is PushNotificationReducerEvent.MarketingConsentSaveFailed -> { + state + .withMarketingConsent(event.consent, !event.requested) + .copy(isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed || state.isMarketingFeedbackActive) + } + + is PushNotificationReducerEvent.PushSettingSaving -> { + state.withValue(event.update.setting, event.update.on).withUpdating(event.update.setting, updating = true) + } + + is PushNotificationReducerEvent.PushSettingSaved -> { + state.withUpdating(event.setting, updating = false) + } + + is PushNotificationReducerEvent.PushSettingSaveFailed -> { + state + .withValue(event.update.setting, event.previous) + .withUpdating(event.update.setting, updating = false) + .copy(saveFailure = event.failure, failedUpdate = event.update) + } + + PushNotificationReducerEvent.SaveFailureDismissed -> { + state.copy(saveFailure = null, failedUpdate = null) + } + + // 비활성으로 바뀌면 아직 소비되지 않은 안내도 걷는다 — 다음 진입에 재생하지 않는다. + is PushNotificationReducerEvent.MarketingFeedbackActiveChanged -> { + state.copy( + isMarketingFeedbackActive = event.active, + isMarketingConsentSaveFailed = state.isMarketingConsentSaveFailed && event.active, + ) + } + + PushNotificationReducerEvent.MarketingConsentSaveFailureConsumed -> { + state.copy(isMarketingConsentSaveFailed = false) + } + } + + private fun refreshDeviceAlarmStatus() { val deviceAlarmOn = NotificationManagerCompat.from(context).areNotificationsEnabled() Log.d(TAG, "refreshDeviceAlarmStatus: deviceAlarmOn=$deviceAlarmOn") - _uiState.update { it.copy(isDeviceAlarmOn = deviceAlarmOn) } + dispatch(PushNotificationReducerEvent.DeviceAlarmStatusRead(deviceAlarmOn)) } private fun loadPushSettings() { viewModelScope.launch { Log.d(TAG, "loadPushSettings: start") - _uiState.update { it.copy(isLoading = true) } + dispatch(PushNotificationReducerEvent.PushSettingsLoading) runCatchingCancellable { notificationRepository.getMyPushSettings() } .onSuccess { setting -> Log.d(TAG, "loadPushSettings: success=$setting") - _uiState.update { - it.copy( - isLoading = false, - isNewsletterOn = setting.timeLetter, - isMindRecordOn = setting.mindRecord, - isAfternoteOn = setting.afterNote, - ) - } + dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) }.onFailure { e -> // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) - _uiState.update { it.copy(isLoading = false) } + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) } } } @@ -88,88 +169,44 @@ internal class PushNotificationViewModel runCatchingCancellable { notificationRepository.getMyMarketingConsents() } .onSuccess { consent -> Log.d(TAG, "loadMarketingConsents: success=$consent") - _uiState.update { - it.copy( - isSmsChecked = consent.sms, - isEmailChecked = consent.email, - isPushChecked = consent.push, - ) - } + dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) }.onFailure { e -> Log.e(TAG, "loadMarketingConsents: failed", e) } } } - fun onSmsChecked(checked: Boolean) { - _uiState.update { it.copy(isSmsChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = checked, email = null, push = null) } - .onSuccess { Log.d(TAG, "onSmsChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_SMS_CONSENT)) - _uiState.update { it.copy(isSmsChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onEmailChecked(checked: Boolean) { - _uiState.update { it.copy(isEmailChecked = checked) } - viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = checked, push = null) } - .onSuccess { Log.d(TAG, "onEmailChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_EMAIL_CONSENT)) - _uiState.update { it.copy(isEmailChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } - } - } - - fun onPushChecked(checked: Boolean) { - _uiState.update { it.copy(isPushChecked = checked) } + private fun updateMarketingConsent( + consent: MarketingConsent, + checked: Boolean, + ) { + dispatch(PushNotificationReducerEvent.MarketingConsentChanged(consent, checked)) viewModelScope.launch { - runCatchingCancellable { notificationRepository.updateMyMarketingConsents(sms = null, email = null, push = checked) } - .onSuccess { Log.d(TAG, "onPushChecked: success, checked=$checked") } - .onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to STAGE_PUSH_CONSENT)) - _uiState.update { it.copy(isPushChecked = !checked) } - _events.tryEmit(PushNotificationEvent.MarketingConsentSaveFailed) - } + runCatchingCancellable { + notificationRepository.updateMyMarketingConsents( + sms = checked.takeIf { consent == MarketingConsent.SMS }, + email = checked.takeIf { consent == MarketingConsent.EMAIL }, + push = checked.takeIf { consent == MarketingConsent.PUSH }, + ) + }.onSuccess { + Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") + }.onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + } } } - fun onNewsletterToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.NEWSLETTER, on)) - } - - fun onMindRecordToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.MIND_RECORD, on)) - } - - fun onAfternoteToggle(on: Boolean) { - updatePushSetting(PushSettingUpdate(PushSetting.AFTERNOTE, on)) - } - - fun onSaveFailureDismiss() { - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } - } - - fun onSaveFailureRetry() { - val update = failedUpdate ?: return - failedUpdate = null - _uiState.update { it.copy(saveFailure = null) } + private fun retrySave() { + val update = currentState.failedUpdate ?: return + dispatch(PushNotificationReducerEvent.SaveFailureDismissed) updatePushSetting(update) } private fun updatePushSetting(update: PushSettingUpdate) { - if (_uiState.value.isUpdating(update.setting)) return - val previousValue = _uiState.value.valueOf(update.setting) - _uiState.update { - it.withValue(update.setting, update.on).withUpdating(update.setting, updating = true) - } + if (currentState.isUpdating(update.setting)) return + val previousValue = currentState.valueOf(update.setting) + dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { runCatchingCancellable { notificationRepository.updateMyPushSettings( @@ -178,7 +215,7 @@ internal class PushNotificationViewModel afterNote = update.on.takeIf { update.setting == PushSetting.AFTERNOTE }, ) }.onSuccess { - _uiState.update { it.withUpdating(update.setting, updating = false) } + dispatch(PushNotificationReducerEvent.PushSettingSaved(update.setting)) }.onFailure { failure -> errorReporter.recordFailure( failure, @@ -187,13 +224,7 @@ internal class PushNotificationViewModel KEY_PUSH_SETTING to update.setting.reportingName(), ), ) - failedUpdate = update - _uiState.update { - it - .withValue(update.setting, previousValue) - .withUpdating(update.setting, updating = false) - .copy(saveFailure = failure.toSaveFailure()) - } + dispatch(PushNotificationReducerEvent.PushSettingSaveFailed(update, previousValue, failure.toSaveFailure())) } } } @@ -203,18 +234,9 @@ internal class PushNotificationViewModel private const val KEY_STAGE = "stage" private const val KEY_PUSH_SETTING = "push_setting" private const val STAGE_PUSH_SETTING_UPDATE = "push_setting_update" - private const val STAGE_SMS_CONSENT = "sms_consent_update" - private const val STAGE_EMAIL_CONSENT = "email_consent_update" - private const val STAGE_PUSH_CONSENT = "push_consent_update" } } -private enum class PushSetting { - NEWSLETTER, - MIND_RECORD, - AFTERNOTE, -} - /** 진단 속성 값. 토글 종류만 담는 고정 문자열이라 사용자 정보가 섞이지 않는다. */ private fun PushSetting.reportingName(): String = when (this) { @@ -223,10 +245,13 @@ private fun PushSetting.reportingName(): String = PushSetting.AFTERNOTE -> "afternote" } -private data class PushSettingUpdate( - val setting: PushSetting, - val on: Boolean, -) +/** 마케팅 동의 저장 실패의 진단 단계. 채널 이름만 담는 고정 문자열이다. */ +private fun MarketingConsent.reportingStage(): String = + when (this) { + MarketingConsent.SMS -> "sms_consent_update" + MarketingConsent.EMAIL -> "email_consent_update" + MarketingConsent.PUSH -> "push_consent_update" + } private fun PushNotificationUiState.valueOf(setting: PushSetting): Boolean = when (setting) { @@ -262,6 +287,16 @@ private fun PushNotificationUiState.withUpdating( PushSetting.AFTERNOTE -> copy(isAfternoteUpdating = updating) } +private fun PushNotificationUiState.withMarketingConsent( + consent: MarketingConsent, + checked: Boolean, +): PushNotificationUiState = + when (consent) { + MarketingConsent.SMS -> copy(isSmsChecked = checked) + MarketingConsent.EMAIL -> copy(isEmailChecked = checked) + MarketingConsent.PUSH -> copy(isPushChecked = checked) + } + private fun Throwable.toSaveFailure(): PushNotificationSaveFailure = when (this) { is PushSettingFailure.NetworkUnavailable -> PushNotificationSaveFailure.NETWORK diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt index d908c8d8e0..039935aac9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/DeviceAlarmOffSection.kt @@ -16,7 +16,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun DeviceAlarmOffSection( +internal fun DeviceAlarmOffSection( uiState: PushNotificationUiState, onSmsCheck: (Boolean) -> Unit, onEmailCheck: (Boolean) -> Unit, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt index aca5f1757f..081e521adb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/component/PushToggleSection.kt @@ -6,7 +6,7 @@ import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.PushNotificationUiState @Composable -fun PushToggleSection( +internal fun PushToggleSection( uiState: PushNotificationUiState, onNewsletterToggle: (Boolean) -> Unit, onMindRecordToggle: (Boolean) -> Unit, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 271ba3a7cd..1192abaab0 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -25,7 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository.ConnectedAccountLinkCall -import com.afternote.feature.setting.presentation.account.ConnectedAccountsEvent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.applock.AppLockSetupScreen import com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel @@ -46,10 +46,8 @@ import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -124,14 +122,13 @@ class SettingAccountSecurityTest { !linkViewModel.uiState.value.isLoading } - composeRule.runOnIdle { linkViewModel.onToggle(provider = "google", enabled = true) } - val request = awaitEvent(linkViewModel.events) + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } - assertEquals(ConnectedAccountsEvent.RequestLink("google"), request) + assertEquals("google", linkViewModel.uiState.value.pendingLinkProvider) assertTrue(linkRepository.connectedLinkCalls.isEmpty()) linkRepository.onLinkConnectedAccount = { _, _ -> throw IllegalStateException("oauth rejected") } - composeRule.runOnIdle { linkViewModel.link(provider = "google", accessToken = "google-token") } + composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { linkViewModel.uiState.value.errorMessage == "계정 연결에 실패했습니다." } @@ -152,7 +149,7 @@ class SettingAccountSecurityTest { } assertTrue(unlinkRepository.connectedUnlinkCalls.isEmpty()) - composeRule.runOnIdle { unlinkViewModel.onToggle(provider = "google", enabled = false) } + composeRule.runOnIdle { unlinkViewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { unlinkRepository.connectedUnlinkCalls.size == 1 } @@ -344,11 +341,6 @@ class SettingAccountSecurityTest { assertTrue(runBlocking { authRepository.isLoggedIn.first() }) } - private fun awaitEvent(events: Flow): T = - runBlocking { - withTimeout(TIMEOUT_MILLIS) { events.first() } - } - private companion object { const val TIMEOUT_MILLIS = 5_000L const val RECEIVER_ID = 77L diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 6f95ca6c67..dcb96eefd0 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -38,6 +38,7 @@ import com.afternote.feature.setting.domain.UpdateReceiverInfoUseCase import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent @@ -46,7 +47,10 @@ import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawUiState import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.MarketingConsent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import com.afternote.feature.setting.presentation.profile.ProfileEditEvent import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState @@ -143,7 +147,7 @@ class SettingCompletionTest { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 1 } @@ -162,7 +166,7 @@ class SettingCompletionTest { } assertFalse(viewModel.uiState.value.isNewsletterOn) - composeRule.runOnIdle { viewModel.onMindRecordToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 2 } @@ -186,10 +190,10 @@ class SettingCompletionTest { assertTrue(viewModel.uiState.value.saveFailure != null) composeRule.runOnIdle { - viewModel.onAfternoteToggle(false) - viewModel.onSmsChecked(true) - viewModel.onEmailChecked(true) - viewModel.onPushChecked(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, true)) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.pushUpdateCalls.size == 3 @@ -228,7 +232,7 @@ class SettingCompletionTest { } composeRule.runOnIdle { - viewModel.link(provider = "google", accessToken = "google-access-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-access-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedLinkCalls.size == 1 @@ -259,7 +263,7 @@ class SettingCompletionTest { ) composeRule.runOnIdle { - viewModel.onToggle(provider = "google", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = false)) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.connectedUnlinkCalls.size == 1 diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index fd9f4e6b30..56787ff68b 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -12,17 +12,17 @@ import com.afternote.core.model.user.UserMarketingConsent import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel -import com.afternote.feature.setting.presentation.notification.PushNotificationEvent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel import com.afternote.feature.setting.presentation.profile.ProfileEditIntent import com.afternote.feature.setting.presentation.profile.ProfileEditUiState import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -100,7 +100,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.link(provider = "google", accessToken = "google-token") + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -125,7 +125,7 @@ class SettingCoroutineCancellationTest { val store = storeHolding(viewModel) runCurrent() - viewModel.onToggle(provider = "naver", enabled = false) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) runCurrent() assertTrue(pending.isStarted) val pendingState = viewModel.uiState.value @@ -225,8 +225,7 @@ class SettingCoroutineCancellationTest { val reporter = RecordingErrorReporter() val viewModel = pushViewModel(repository, reporter) val store = storeHolding(viewModel) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 푸시 설정 조회는 먼저 정상 종료해 안정된 상태를 만든다. 매달린 것은 마케팅 조회뿐이다. @@ -239,7 +238,7 @@ class SettingCoroutineCancellationTest { assertTrue(pending.isCancelled) assertEquals(stableState, viewModel.uiState.value) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, reporter.recordedStages.size) assertEquals(emptyList(), pushFailureLogs()) } @@ -273,8 +272,7 @@ class SettingCoroutineCancellationTest { }, failureReporter, ) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { failureViewModel.events.collect { events += it } } + failureViewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) runCurrent() // 평범한 실패는 진단 로그 한 줄로 끝난다. 화면 값은 기본값 그대로이고 안내도 띄우지 않는다. @@ -282,7 +280,7 @@ class SettingCoroutineCancellationTest { assertTrue(failureViewModel.uiState.value.isSmsChecked) assertTrue(failureViewModel.uiState.value.isEmailChecked) assertFalse(failureViewModel.uiState.value.isPushChecked) - assertTrue(events.isEmpty()) + assertFalse(failureViewModel.uiState.value.isMarketingConsentSaveFailed) assertEquals(0, failureReporter.recordedStages.size) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 2e092dface..320a7632f4 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -16,7 +16,9 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.home.SettingScreen import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -106,7 +108,7 @@ class SettingFlowTest { composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isLoading } - composeRule.runOnIdle { viewModel.onNewsletterToggle(false) } + composeRule.runOnIdle { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) } composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.isNewsletterOn } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt new file mode 100644 index 0000000000..6baf72799c --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt @@ -0,0 +1,149 @@ +package com.afternote.feature.setting.presentation.account + +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectedAccountsMviTest { + private val repository = FakeSettingAccountRepository(connectedAccounts = ACCOUNTS) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `토글을 켜면 저장소를 부르지 않고 연결 요청 신호만 올린다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + + assertEquals("google", viewModel.uiState.value.pendingLinkProvider) + assertTrue(repository.connectedLinkCalls.isEmpty()) + assertTrue(repository.connectedUnlinkCalls.isEmpty()) + } + + @Test + fun `토글을 끄면 신호 없이 바로 해제한다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + + assertNull(viewModel.uiState.value.pendingLinkProvider) + assertEquals(listOf("naver"), repository.connectedUnlinkCalls) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "naver" } + .isConnected, + ) + } + + @Test + fun `연결 요청 신호는 소비할 때까지 남고 소비하면 걷힌다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + // 다른 작업이 지나가도 신호는 그대로다. + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "naver", enabled = false)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + + // 소비 뒤 같은 제공자를 다시 켜면 다시 요청한다 (A → null → A). + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않는다`() { + val viewModel = ConnectedAccountsViewModel(repository) + + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "kakao", enabled = true)) + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("google")) + + assertEquals("kakao", viewModel.uiState.value.pendingLinkProvider) + + viewModel.onIntent(ConnectedAccountsIntent.ConsumeLinkRequest("kakao")) + assertNull(viewModel.uiState.value.pendingLinkProvider) + } + + @Test + fun `연결이 연달아 실패해도 매번 요청하고 실패 문구를 남긴 뒤 성공하면 계정을 갱신한다`() { + var remainingFailures = 2 + repository.onLinkConnectedAccount = { provider, _ -> + if (remainingFailures > 0) { + remainingFailures-- + error("oauth rejected") + } + ACCOUNTS.copy(google = provider == "google", googleEmail = "linked@afternote.local") + } + val viewModel = ConnectedAccountsViewModel(repository) + + repeat(2) { + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-$it")) + assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.errorMessage) + assertFalse( + viewModel.uiState.value.accounts + .first { it.provider == "google" } + .isConnected, + ) + } + viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-2")) + + assertEquals( + List(3) { FakeSettingAccountRepository.ConnectedAccountLinkCall("google", "token-$it") }, + repository.connectedLinkCalls.toList(), + ) + val google = + viewModel.uiState.value.accounts + .first { it.provider == "google" } + assertTrue(google.isConnected) + assertEquals("linked@afternote.local", google.email) + } + + @Test + fun `조회 실패는 로딩을 끝내고 실패 문구를 남긴다`() { + repository.onGetConnectedAccounts = { error("unavailable") } + + val state = ConnectedAccountsViewModel(repository).uiState.value + + assertFalse(state.isLoading) + assertTrue(state.accounts.isEmpty()) + assertEquals("계정 정보를 불러올 수 없습니다.", state.errorMessage) + } + + private companion object { + val ACCOUNTS = + UserConnectedAccount( + local = false, + naver = true, + google = false, + kakao = false, + apple = false, + localEmail = null, + naverEmail = "naver@afternote.local", + googleEmail = null, + kakaoEmail = null, + appleEmail = null, + ) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt new file mode 100644 index 0000000000..5b714026d0 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsSignalTest.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.setting.presentation.account + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 연결된 계정 화면이 연결 요청 신호를 받자마자 소비하고, 인증은 신호와 떨어진 화면 코루틴에서 + * 돌리는지 확인한다 (#1502). 플랫폼 인증은 이 환경에서 토큰을 주지 못하므로 연결 호출까지는 가지 않는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ConnectedAccountsSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val repository = FakeSettingAccountRepository() + private val viewModel = ConnectedAccountsViewModel(repository) + + @Test + fun `화면은 연결 요청 신호를 받으면 곧바로 소비하고 다음 요청도 다시 받는다`() { + composeRule.setContent { + AfternoteTheme { + ConnectedAccountsScreen(onBack = {}, viewModel = viewModel) + } + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isLoading } + + repeat(2) { + composeRule.runOnIdle { viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider = "google", enabled = true)) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingLinkProvider == null } + } + + assertTrue(repository.connectedLinkCalls.isEmpty()) + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt index 6d88a442f7..b2091e81a1 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/MarketingConsentFailureTest.kt @@ -8,7 +8,6 @@ import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepos import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -17,6 +16,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -49,23 +49,21 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isSmsChecked) - viewModel.onEmailChecked(false) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isEmailChecked) - viewModel.onPushChecked(false) - advanceUntilIdle() - runCurrent() - assertTrue(viewModel.uiState.value.isPushChecked) + var signals = 0 + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + assertFalse(viewModel.uiState.value.checked(consent)) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.checked(consent)) + if (viewModel.uiState.value.isMarketingConsentSaveFailed) signals++ + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + } - assertEquals(List(3) { PushNotificationEvent.MarketingConsentSaveFailed }, events) + assertEquals(3, signals) assertEquals(listOf("sms_consent_update", "email_consent_update", "push_consent_update"), reporter.stages) assertEquals( listOf(Triple(false, null, null), Triple(null, false, null), Triple(null, null, false)), @@ -82,14 +80,12 @@ class MarketingConsentFailureTest { onUpdateMyMarketingConsents = { _, _, _ -> throw CancellationException("screen left") } } val viewModel = viewModel(repository, reporter) - val events = mutableListOf() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) - viewModel.onEmailChecked(false) - viewModel.onPushChecked(false) + MarketingConsent.entries.forEach { consent -> + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(consent, false)) + } advanceUntilIdle() runCurrent() @@ -97,39 +93,83 @@ class MarketingConsentFailureTest { listOf(false, false, false), viewModel.uiState.value.let { listOf(it.isSmsChecked, it.isEmailChecked, it.isPushChecked) }, ) - assertTrue(events.isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) assertTrue(reporter.stages.isEmpty()) } @Test - fun `구독이 끝난 동안의 실패 안내는 다음 구독에 재생하지 않는다`() = + fun `STARTED 밖에서 난 실패 안내는 다음 진입에 재생하지 않는다`() = + runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + advanceUntilIdle() + // 값 롤백은 화면 수명과 무관하게 일어난다. 안내만 막힌다. + assertTrue(viewModel.uiState.value.isSmsChecked) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `소비하기 전에 화면이 멈추면 남은 안내를 걷어 재진입에 띄우지 않는다`() = runTest(dispatcher) { + val viewModel = failingViewModel() + advanceUntilIdle() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.PUSH, true)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) + + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + @Test + fun `안내는 소비할 때까지 남고 소비 뒤 연속 실패는 다시 안내한다`() = + runTest(dispatcher) { + val reporter = RecordingReporter() val viewModel = viewModel( - repository().apply { - onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } - }, - RecordingReporter(), + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + reporter, ) - val events = mutableListOf() - val firstCollector = backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } advanceUntilIdle() - runCurrent() - firstCollector.cancel() - runCurrent() + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - viewModel.onSmsChecked(false) + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - backgroundScope.launch(dispatcher) { viewModel.events.collect { events += it } } - runCurrent() - assertTrue(events.isEmpty()) + // 다른 입력이 지나가도 소비 전까지 신호는 남는다. + viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) - viewModel.onEmailChecked(false) + viewModel.onIntent(PushNotificationIntent.ConsumeMarketingConsentSaveFailure) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) advanceUntilIdle() - runCurrent() - assertEquals(listOf(PushNotificationEvent.MarketingConsentSaveFailed), events) + assertTrue(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + assertEquals(listOf("sms_consent_update", "sms_consent_update"), reporter.stages) } + private fun failingViewModel() = + viewModel( + repository().apply { onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } }, + RecordingReporter(), + ) + private fun repository() = FakeSettingNotificationRepository.strict().apply { onGetMyPushSettings = { UserPushSetting(false, false, false) } @@ -141,6 +181,13 @@ class MarketingConsentFailureTest { reporter: ErrorReporter, ) = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, reporter) + private fun PushNotificationUiState.checked(consent: MarketingConsent): Boolean = + when (consent) { + MarketingConsent.SMS -> isSmsChecked + MarketingConsent.EMAIL -> isEmailChecked + MarketingConsent.PUSH -> isPushChecked + } + private class RecordingReporter : ErrorReporter { val stages = mutableListOf() diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt new file mode 100644 index 0000000000..61669efa71 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -0,0 +1,117 @@ +package com.afternote.feature.setting.presentation.notification + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 알림 설정 화면이 마케팅 동의 저장 실패 신호를 받아 안내하고 소비하는지, STARTED 밖의 실패를 + * 재진입에 재생하지 않는지 화면 배선까지 확인한다 (#1502, #558). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class NotificationSettingSignalTest { + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val failedMessage = context.getString(R.string.setting_marketing_consent_save_failed) + private val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { UserPushSetting(timeLetter = false, mindRecord = false, afterNote = false) } + onGetMyMarketingConsents = { UserMarketingConsent(sms = true, email = true, push = true) } + onUpdateMyMarketingConsents = { _, _, _ -> error("unavailable") } + } + private val viewModel = PushNotificationViewModel(context, repository, NoOpErrorReporter) + private val lifecycleOwner = ManualLifecycleOwner() + + @Test + fun `저장 실패 신호를 받으면 안내를 띄우고 신호를 소비한다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithText(failedMessage).assertExists() + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + assertTrue(viewModel.uiState.value.isSmsChecked) + } + + @Test + fun `STARTED 밖에서 난 저장 실패는 다시 시작해도 안내하지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { !viewModel.uiState.value.isMarketingFeedbackActive } + + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 1 && viewModel.uiState.value.isEmailChecked + } + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + composeRule.waitForIdle() + + assertTrue(composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isEmpty()) + assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) + } + + private fun setScreen() { + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + AfternoteTheme { + NotificationSettingScreen( + onBack = {}, + onPushNotificationClick = {}, + viewModel = viewModel, + ) + } + } + } + } + + /** 화면이 보는 수명만 따로 움직인다. 호스트 액티비티는 RESUMED 로 둔 채 STARTED 밖을 흉내 낸다. */ + private class ManualLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + + override val lifecycle: Lifecycle get() = registry + + fun moveTo(state: Lifecycle.State) { + registry.currentState = state + } + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt index df72f27a59..1068f0f479 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationSaveReportingTest.kt @@ -56,19 +56,19 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) assertFalse(viewModel.uiState.value.isAfternoteUpdating) @@ -91,9 +91,9 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(FakeSettingNotificationRepository(pushSetting = ALL_ON), reporter) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onMindRecordToggle(false) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertFalse(viewModel.uiState.value.isNewsletterOn) @@ -119,7 +119,7 @@ class PushNotificationSaveReportingTest { val store = ViewModelStore().apply { put(STORE_KEY, viewModel) } runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertEquals(1, repository.pushUpdateCalls.size) @@ -166,12 +166,12 @@ class PushNotificationSaveReportingTest { val viewModel = viewModel(repository, reporter) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) runCurrent() assertFalse(viewModel.uiState.value.isMindRecordOn) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt index 8a3276dbaf..dc8063ec7d 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModelTest.kt @@ -47,7 +47,7 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -67,22 +67,22 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = Int.MAX_VALUE) runCurrent() - viewModel.onNewsletterToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) assertFalse(viewModel.uiState.value.isNewsletterOn) runCurrent() assertTrue(viewModel.uiState.value.isNewsletterOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) assertNull(viewModel.uiState.value.saveFailure) - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) assertFalse(viewModel.uiState.value.isMindRecordOn) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureDismiss() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) assertFalse(viewModel.uiState.value.isAfternoteOn) runCurrent() assertTrue(viewModel.uiState.value.isAfternoteOn) @@ -105,13 +105,13 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) runCurrent() - viewModel.onMindRecordToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) runCurrent() assertTrue(viewModel.uiState.value.isMindRecordOn) assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) - viewModel.onSaveFailureRetry() + viewModel.onIntent(PushNotificationIntent.RetrySave) assertNull(viewModel.uiState.value.saveFailure) assertFalse(viewModel.uiState.value.isMindRecordOn) @@ -139,7 +139,7 @@ class PushNotificationViewModelTest { ) runCurrent() - viewModel.onAfternoteToggle(false) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) runCurrent() assertEquals(PushNotificationSaveFailure.NETWORK, viewModel.uiState.value.saveFailure) @@ -154,8 +154,8 @@ class PushNotificationViewModelTest { val viewModel = viewModel(calls = calls) runCurrent() - viewModel.onNewsletterToggle(false) - viewModel.onNewsletterToggle(true) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) assertFalse(viewModel.uiState.value.isNewsletterOn) assertTrue(viewModel.uiState.value.isNewsletterUpdating) @@ -167,6 +167,76 @@ class PushNotificationViewModelTest { assertFalse(viewModel.uiState.value.isNewsletterUpdating) } + @Test + fun `재시도를 연달아 보내도 실패한 변경을 한 번만 다시 저장한다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, false)) + runCurrent() + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + viewModel.onIntent(PushNotificationIntent.RetrySave) + assertTrue(viewModel.uiState.value.isAfternoteUpdating) + runCurrent() + + assertEquals( + List(2) { PushUpdateCall(timeLetter = null, mindRecord = null, afterNote = false) }, + calls, + ) + assertFalse(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isAfternoteUpdating) + assertNull(viewModel.uiState.value.failedUpdate) + } + + @Test + fun `실패 안내를 닫으면 재시도 대상도 비워 이후 재시도가 요청을 보내지 않는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 1) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + runCurrent() + viewModel.onIntent(PushNotificationIntent.DismissSaveFailure) + + assertNull(viewModel.uiState.value.saveFailure) + assertNull(viewModel.uiState.value.failedUpdate) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(listOf(PushUpdateCall(timeLetter = false, mindRecord = null, afterNote = null)), calls) + assertTrue(viewModel.uiState.value.isNewsletterOn) + } + + @Test + fun `서로 다른 토글이 연달아 실패하면 재시도 대상은 마지막 실패만 남는다`() = + runTest(dispatcher) { + val calls = mutableListOf() + val viewModel = viewModel(calls = calls, failUpdateAttempts = 2) + runCurrent() + + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, false)) + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, false)) + runCurrent() + + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertEquals(PushNotificationSaveFailure.SERVER, viewModel.uiState.value.saveFailure) + + viewModel.onIntent(PushNotificationIntent.RetrySave) + runCurrent() + + assertEquals(PushUpdateCall(timeLetter = null, mindRecord = false, afterNote = null), calls.last()) + assertEquals(3, calls.size) + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertFalse(viewModel.uiState.value.isMindRecordOn) + } + private fun viewModel( calls: MutableList, failUpdateAttempts: Int = 0, diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 26d7bb7cf9..10f60a3229 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -26,6 +26,10 @@ import java.io.File * 49개는 [PENDING_MVI_MIGRATION] 예외로 두고, 모듈 전환 이슈가 닫힐 때마다 뺀다. * - **C.** `MviIntent`·`ReducerEvent` 를 직접 구현하는 화면 계약 타입은 `sealed interface` 다. * 열려 있으면 `when` 이 전수 분기를 보장하지 못해 진입점 단일화의 이득이 사라진다. + * - **D.** 아직 전환하지 않은 `feature/…/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 + * 새로 들이지 않는다 (#1502). 규칙 A 는 전환한 ViewModel 만 보므로, 이 규칙이 없으면 [PENDING_MVI_MIGRATION] + * 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘릴 수 있다. 가드 도입 시점에 이미 있던 선언은 + * [LEGACY_EVENT_STREAMS] 기준선으로 인정한다. `MutableStateFlow` 는 전환 전 상태 홀더라 대상이 아니다. * * 기준 문서는 `docs/convention/mvi.md` 다. * @@ -93,6 +97,38 @@ class MviContractKonsistTest { ) } + @Test + fun `미전환 ViewModel 도 이벤트 스트림을 새로 들이지 않는다`() { + val violations = legacyEventStreams(AfternoteKonsistScope.productionFiles) - LEGACY_EVENT_STREAMS + + check(violations.isEmpty()) { + buildString { + appendLine("MVI 미전환 ViewModel 이 이벤트용 Channel·MutableSharedFlow 를 새로 선언한다 (${violations.size}건).") + appendLine("producer 가 consumer 보다 오래 살면 Channel·SharedFlow 는 전달을 보장하지 못한다 (#228).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("일회성 신호는 UiState 의 nullable 필드로 흡수하고 소비는 Intent 로 받는다 (#1502).") + appendLine("기존 선언을 옮긴 것이면 ViewModel 을 MviViewModel 로 전환한다. 기준: docs/convention/mvi.md") + } + } + } + + @Test + fun `해소된 이벤트 스트림 기준선은 경고로 알린다`() { + val stale = LEGACY_EVENT_STREAMS - legacyEventStreams(AfternoteKonsistScope.productionFiles) + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] LEGACY_EVENT_STREAMS 에 지금 소스에 없는 항목이 남아 있다 (${stale.size}건).") + appendLine("전환이 끝났으면 지운다 — 남겨 두면 같은 이름으로 다시 들인 스트림이 기준선에 숨는다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + @Test fun `Intent 와 ReducerEvent 는 sealed interface 다`() { val violations = openContractTypes(AfternoteKonsistScope.productionFiles) @@ -219,6 +255,60 @@ class MviContractKonsistTest { check(violations.any { it.endsWith("object Dismiss") }) { violations.toString() } } + @Test + fun `규칙 D - 미전환 ViewModel 의 Channel·MutableSharedFlow 만 잡는다`() { + val root = fixture.newFolder("rule-d") + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/LegacyViewModel.kt", + """ + package sample + + class LegacyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SampleUiState()) + private val _events = Channel(Channel.BUFFERED) + private val signals = MutableSharedFlow(replay = 0) + private val plain = 0 + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/MigratedViewModel.kt", + """ + package sample + + class MigratedViewModel : MviViewModel(SampleUiState()) { + private val signals = Channel() + } + """, + ) + root.writeKotlin( + "feature/sample/presentation/src/main/kotlin/sample/SnackbarRelay.kt", + """ + package sample + + class SnackbarRelay { + private val messages = Channel() + } + """, + ) + root.writeKotlin( + "app/src/main/kotlin/app/AppViewModel.kt", + """ + package app + + class AppViewModel : ViewModel() { + private val _events = Channel() + } + """, + ) + + val violations = legacyEventStreams(fixtureFiles(root)) + + // 상태 홀더(MutableStateFlow)는 전환 전 ViewModel 의 정상 구조다. 전환한 ViewModel 은 규칙 A, + // ViewModel 이 아닌 타입과 app 은 이 규칙 밖이다(app 은 #1809 몫). + assertEquals(setOf("sample.LegacyViewModel._events", "sample.LegacyViewModel.signals"), violations) + } + @Test fun `규칙 A·B - 중간 추상 베이스를 낀 상속도 MviViewModel 로 본다`() { val root = fixture.newFolder("indirect-parents") @@ -267,6 +357,25 @@ class MviContractKonsistTest { }.toSet() } + /** 규칙 D 위반 후보 — `feature/…/presentation` main 소스의 미전환 ViewModel 이 든 이벤트 스트림(`FQN.프로퍼티`). */ + private fun legacyEventStreams(files: List): Set { + val extendsMvi = mviViewModelSubclassTest(files) + return files + .filter { FEATURE_PRESENTATION_MAIN.containsMatchIn(it.normalizedProjectPath()) } + .flatMap { file -> + file + .classes() + .filter { it.name.endsWith(VIEW_MODEL_SUFFIX) } + .filterNot(extendsMvi) + .flatMap { declaration -> + declaration + .properties() + .filter { property -> EVENT_STREAM.containsMatchIn(property.text) } + .map { property -> "${file.packagee?.name}.${declaration.name}.${property.name}" } + } + }.toSet() + } + /** 규칙 A 위반 — 베이스를 상속한 ViewModel 이 직접 든 상태 홀더. */ private fun stateHolderDeclarations(files: List): List { val extendsMvi = mviViewModelSubclassTest(files) @@ -363,6 +472,21 @@ class MviContractKonsistTest { */ val STATE_HOLDER = Regex("""\b(MutableStateFlow|MutableSharedFlow|Channel)\s*[(<]""") + /** 규칙 D 의 이벤트 스트림 선언. [STATE_HOLDER] 에서 전환 전 상태 홀더인 `MutableStateFlow` 만 뺐다. */ + val EVENT_STREAM = Regex("""\b(MutableSharedFlow|Channel)\s*[(<]""") + + /** + * 규칙 D 의 기준선 — 가드 도입(#1502) 때 이미 있던 미전환 ViewModel 의 이벤트 스트림. + * + * 새 항목은 넣지 않는다. 해당 ViewModel 이 전환되거나 지워지면 여기서도 뺀다(남으면 경고). + */ + val LEGACY_EVENT_STREAMS = + setOf( + // #944 가 카카오 초대(BE#289)로 교체한다. 폼 값과 이메일 등록 버튼이 PM 결정 대기라 + // #1502 전환 범위에서 뺐다. + "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel._events", + ) + /** #1804 가 뺀다. */ private val ISSUE_1804_AFTERNOTE = setOf( @@ -426,13 +550,14 @@ class MviContractKonsistTest { "com.afternote.feature.receiver.presentation.senderdetail.SenderDetailViewModel", ) - /** #1805 가 뺀다. `Channel` 5곳 흡수(#1502)가 선행이다. */ + /** + * #1805 가 뺀다. 이벤트 스트림을 쓰던 ConnectedAccounts·PushNotification 은 #1502 가 전환했다. + * ReceiverRegisterViewModel 은 #944 가 카카오 초대로 교체하며 뺀다([LEGACY_EVENT_STREAMS]). + */ private val ISSUE_1805_SETTING = setOf( - "com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel", "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", "com.afternote.feature.setting.presentation.home.SettingViewModel", - "com.afternote.feature.setting.presentation.notification.PushNotificationViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", ) From e0af7b118781ca01582757db327854385bd8a16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 17:57:01 +0900 Subject: [PATCH 037/116] =?UTF-8?q?fix(setting):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=A9=88?= =?UTF-8?q?=EC=B6=94=EB=A9=B4=20=EB=96=A0=20=EC=9E=88=EB=8D=98=20=EB=A7=88?= =?UTF-8?q?=EC=BC=80=ED=8C=85=20=EB=8F=99=EC=9D=98=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=8F=84=20=EA=B1=B0=EB=91=94=EB=8B=A4=20(#1?= =?UTF-8?q?502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVI 전환 뒤 실패 안내 스낵바를 화면 코루틴(rememberCoroutineScope)에 띄우면서, 화면이 STOP 돼도 떠 있던 안내와 그 뒤에 줄 선 안내가 취소되지 않고 재진입에 떴다. 예전 repeatOnLifecycle(STARTED) 수집은 멈출 때 스낵바를 함께 취소했으므로 #558 의 재진입 재생 방지가 그 경로에서 깨진 것이다. - onStopOrDispose 에서 스낵바 작업을 취소해 예전 동작을 되살린다. - 두 실패가 겹친 채 멈췄다 돌아오는 화면 테스트를 더한다. 수정 전 코드에서 실패하는 것을 확인했다. - konsist 머리 KDoc 의 규칙 개수를 넷으로 고친다(규칙 D 추가분). --- .../notification/NotificationSettingScreen.kt | 12 +++++-- .../NotificationSettingSignalTest.kt | 33 +++++++++++++++++++ .../konsist/MviContractKonsistTest.kt | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index df4342536b..dfe059cdfa 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -45,6 +45,7 @@ import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.component.DeviceAlarmOffSection import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.launch @Composable @@ -84,14 +85,19 @@ internal fun NotificationSettingScreen( viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) } + // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. + val snackbarScope = rememberCoroutineScope() + // 화면이 STARTED 인 동안만 마케팅 동의 저장 실패를 안내한다. 화면이 없는 동안의 실패는 다음 진입에 재생하지 않는다 (#558). + // 멈출 때는 떠 있던 안내와 줄 선 안내도 거둔다. 화면 코루틴은 STOP 에 취소되지 않으므로 그대로 두면 재진입에 뜬다. LifecycleStartEffect(viewModel) { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStarted) - onStopOrDispose { viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) } + onStopOrDispose { + viewModel.onIntent(PushNotificationIntent.MarketingFeedbackStopped) + snackbarScope.coroutineContext.cancelChildren() + } } - // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. - val snackbarScope = rememberCoroutineScope() ObserveFlag( raised = uiState.isMarketingConsentSaveFailed, consumed = PushNotificationIntent.ConsumeMarketingConsentSaveFailure, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt index 61669efa71..4ef2fdb83c 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -86,6 +86,36 @@ class NotificationSettingSignalTest { assertFalse(viewModel.uiState.value.isMarketingConsentSaveFailed) } + @Test + fun `안내 중에 화면이 멈추면 떠 있던 안내와 밀린 안내를 거둬 재진입에 띄우지 않는다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + + // 첫 실패의 안내가 떠 있는 동안 둘째 실패가 나서 그 뒤에 줄을 선다. + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { isFailedMessageShown() } + composeRule.runOnIdle { + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.EMAIL, false)) + } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.marketingConsentUpdates.size == 2 && + viewModel.uiState.value.isEmailChecked && + !viewModel.uiState.value.isMarketingConsentSaveFailed + } + + // 안내 시간이 흘러 저절로 닫히지 않게 시계를 세운다. 멈췄다 돌아오는 것만으로 걷히는지 본다. + composeRule.mainClock.autoAdvance = false + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.CREATED) } + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.mainClock.advanceTimeBy(SNACKBAR_EXIT_MILLIS) + + assertFalse(isFailedMessageShown()) + } + + private fun isFailedMessageShown(): Boolean = composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() + private fun setScreen() { composeRule.setContent { CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { @@ -113,5 +143,8 @@ class NotificationSettingSignalTest { private companion object { const val TIMEOUT_MILLIS = 5_000L + + /** 스낵바가 사라지는 애니메이션이 끝나기에 넉넉하고, 짧은 안내 시간(4초)보다는 짧다. */ + const val SNACKBAR_EXIT_MILLIS = 1_000L } } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 10f60a3229..75710a336b 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -17,7 +17,7 @@ import java.io.File * 정착했는데 그 아래 전이 경로는 화면마다 갈렸고, 진입점을 하나로 모은 화면은 51개 중 * 1개뿐이었다. 규약을 문서로만 두면 같은 일이 반복된다. * - * 규칙은 셋이다. + * 규칙은 넷이다. * - **A.** `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 * 선언하지 않는다. 별도 상태 홀더를 두면 전이가 `reduce` 밖으로 새고 베이스를 도입한 의미가 * 사라진다. `Channel` 금지는 #1502 가 세우려는 규칙과 같은 방향이라 **두 규칙을 하나로 합친다** — From 29798d183e45260543ed4742475e2eb2e276a35b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 18:31:43 +0900 Subject: [PATCH 038/116] =?UTF-8?q?fix(platform):=20dependabot=20PR=20?= =?UTF-8?q?=EC=9D=98=20=EB=91=90=20=EA=B2=80=EC=A6=9D=EC=9D=B4=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EA=B0=80=20=EC=B1=84=EC=9A=B4=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=C2=B7=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A0=B8=EB=8B=A4=EA=B0=80=20=ED=8C=90=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게이트는 GITHUB_TOKEN 으로 편집해 edited 가 다른 워크플로를 깨우지 못하고, PR Validation·Managed Device 는 opened 시점 페이로드를 읽어 red 로 남았다. dependabot 이 연 PR 의 pull_request run 에서만 두 검증이 live PR 을 needsGate 가 false 가 될 때까지 기다린 뒤 그 값으로 판정한다. 기기 레인은 default branch 신뢰 사본으로 기다린다. 게이트 권한은 늘리지 않는다. --- .github/scripts/dependabot-pr-gate.mjs | 82 +++++++- .github/scripts/dependabot-pr-gate.test.mjs | 187 ++++++++++++++++++- .github/workflows/android-managed-device.yml | 36 +++- .github/workflows/dependabot-pr-gate.yml | 5 + .github/workflows/repository-quality.yml | 8 + 5 files changed, 310 insertions(+), 8 deletions(-) diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index 58b0e7a7b0..e520bf81a5 100644 --- a/.github/scripts/dependabot-pr-gate.mjs +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -6,8 +6,17 @@ // 를 여기서 한다. mode 는 사람이 정하지 않는다. `ci-test-plan.mjs` 가 변경 파일로 판정한 // 결과를 그대로 쓴다. 게이트 면제는 하지 않는다 — 봇 PR 도 워크플로 파일을 건드린다. // -// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)와 GitHub 호출(main)을 갈라 두어 -// 정책은 dependabot-pr-gate.test.mjs 가 잠근다. +// 게이트는 GITHUB_TOKEN 으로 제목·본문을 고치므로 그 `edited` 는 다른 워크플로를 깨우지 못한다. +// 같은 순간 열린 PR Validation·Managed Device 는 opened 시점 페이로드를 읽어 red 로 남았다(#2212). +// 게이트가 검증을 다시 부르는 길은 막혀 있다. 같은 sha 에 dispatch 로 붙인 초록은 PR 롤업이 옛 +// `pull_request` 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 그대로 쓰는 데다 기기 레인의 +// 1회차 인프라 복구를 잃으며, App 토큰은 이 브리지에 금지된 시크릿이 필요하다. 그래서 방향을 +// 뒤집었다. 검증이 `await` 명령으로 게이트가 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다. +// +// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출(main)을 +// 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. + +import { writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -53,6 +62,32 @@ export function buildPlan(changedPaths) { }; } +/** `await` 가 live PR 을 다시 조회하는 간격. 게이트는 job 시작 뒤 수 초 안에 끝난다(#2138 실측 7초). */ +export const AWAIT_POLL_INTERVAL_MS = 5_000; + +/** + * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). dependabot 이 연 열린 PR 이 + * 아니거나 이미 채워졌으면 첫 조회 결과를 바로 돌려준다. 제한 시간이 지나면 기다림을 끝내고 마지막 + * live 상태를 돌려준다. 판정은 호출한 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 + * 실패한다. + */ +export async function awaitGate({ loadPullRequest, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { + const deadline = Date.now() + timeoutMs; + for (let polls = 1; ; polls += 1) { + const pullRequest = await loadPullRequest(); + const waiting = + pullRequest?.user?.login === DEPENDABOT_LOGIN && pullRequest?.state === "open" && needsGate(pullRequest); + if (!waiting) { + return { pullRequest, polls, timedOut: false }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { pullRequest, polls, timedOut: true }; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining))); + } +} + /** 대표 이슈. `.github/ISSUE_TEMPLATE/issue.yml` 양식이라 reconcile-issue-metadata 가 어사인·라벨을 맞춘다. */ export function buildIssue({ title, prNumber, changedPaths, plan }) { const summary = String(title ?? "").replace(/^chore\(deps\):\s*/i, "").trim() || `PR #${prNumber}`; @@ -153,7 +188,7 @@ async function listChangedPaths(apiUrl, repository, prNumber, token) { return paths; } -async function main() { +async function fillGate() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; const prNumber = Number(process.env.PR_NUMBER); @@ -200,8 +235,47 @@ async function main() { console.log(`#${prNumber} 제목·본문 갱신: Refs #${issueNumber}, androidTest mode=${plan.mode}`); } +/** + * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 live PR JSON 을 + * 출력 경로에 쓴다. 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + */ +async function awaitMain(outputPath) { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = Number(process.env.PR_NUMBER); + const timeoutSeconds = Number(process.env.AWAIT_TIMEOUT_SECONDS); + const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; + if (!outputPath || !token || !repository || !Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("await <출력 경로> 와 GITHUB_TOKEN(또는 GH_TOKEN), GITHUB_REPOSITORY, PR_NUMBER 가 필요합니다"); + } + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 0) { + throw new Error(`AWAIT_TIMEOUT_SECONDS 는 0 이상의 정수여야 합니다: ${process.env.AWAIT_TIMEOUT_SECONDS}`); + } + + const { pullRequest, polls, timedOut } = await awaitGate({ + loadPullRequest: () => github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token), + timeoutMs: timeoutSeconds * 1000, + }); + await writeFile(outputPath, JSON.stringify(pullRequest)); + if (timedOut) { + console.log( + `::warning::dependabot-pr-gate 가 ${timeoutSeconds}초 안에 #${prNumber} 의 제목·본문을 채우지 않았습니다. ` + + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다.", + ); + } else if (polls > 1) { + console.log(`#${prNumber} 게이트가 채운 제목·본문을 ${polls}번째 조회에서 확인`); + } +} + if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { + const [command, ...rest] = process.argv.slice(2); + const run = + command === undefined + ? fillGate + : command === "await" + ? () => awaitMain(rest[0]) + : () => Promise.reject(new Error(`알 수 없는 명령: ${command}. 인자 없이(채우기) 또는 await 만 받습니다`)); + run().catch((error) => { console.error(`::error::${error.message}`); process.exitCode = 1; }); diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs index 1a1aa9c31b..3f75adcd1c 100644 --- a/.github/scripts/dependabot-pr-gate.test.mjs +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { inspectCiTestPlan } from "./ci-test-plan.mjs"; import { @@ -9,11 +15,15 @@ import { ISSUE_ASSIGNEES, ISSUE_LABELS, applyGate, + awaitGate, buildIssue, buildPlan, needsGate, } from "./dependabot-pr-gate.mjs"; +const GATE_SCRIPT = fileURLToPath(new URL("./dependabot-pr-gate.mjs", import.meta.url)); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const BOT_TITLE = "chore(deps): bump the github-actions-updates group with 3 updates"; const BOT_BODY = "Bumps the github-actions-updates group with 3 updates: actions/setup-java ..."; @@ -102,3 +112,178 @@ test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않 assert.match(source, /pull-requests: write/); assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); }); + +const FILLED = { + user: { login: DEPENDABOT_LOGIN }, + state: "open", + title: `${BOT_TITLE} (#2139)`, + body: `${GATE_MARKER}\nRefs #2139\n\n${BOT_BODY}`, + head: { sha: "a".repeat(40) }, + changed_files: 3, +}; +const UNFILLED = { ...FILLED, title: BOT_TITLE, body: BOT_BODY }; + +function sequence(...pullRequests) { + let calls = 0; + return { + load: async () => pullRequests[Math.min(calls++, pullRequests.length - 1)], + calls: () => calls, + }; +} + +test("await 는 게이트가 채운 뒤의 live PR 을 돌려준다 (#2212)", async () => { + const live = sequence(UNFILLED, UNFILLED, FILLED); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + + assert.equal(result.timedOut, false); + assert.equal(result.polls, 3); + assert.equal(result.pullRequest, FILLED); + assert.equal(needsGate(result.pullRequest), false); +}); + +test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () => { + for (const pullRequest of [ + FILLED, + { ...UNFILLED, user: { login: "1hyok" } }, + { ...UNFILLED, state: "closed" }, + ]) { + const live = sequence(pullRequest); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 1, JSON.stringify(pullRequest.user)); + assert.equal(result.timedOut, false); + assert.equal(result.pullRequest, pullRequest); + } +}); + +test("await 는 제한 시간이 지나면 기다림을 끝내고 마지막 live 상태를 넘긴다", async () => { + const live = sequence(UNFILLED); + const started = Date.now(); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 60, intervalMs: 10 }); + + assert.equal(result.timedOut, true); + assert.equal(result.pullRequest, UNFILLED); + assert.ok(live.calls() >= 2, "제한 시간 안에서는 다시 조회한다"); + assert.ok(Date.now() - started < 1_000, "무기한 대기하지 않는다"); + + const once = sequence(UNFILLED); + const immediate = await awaitGate({ loadPullRequest: once.load, timeoutMs: 0, intervalMs: 10 }); + assert.equal(immediate.timedOut, true); + assert.equal(once.calls(), 1); +}); + +async function runGateCli(args, { env = {}, pullRequest } = {}) { + const requests = []; + const server = createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(pullRequest ?? {})); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + try { + const result = await promisify(execFile)(process.execPath, [GATE_SCRIPT, ...args], { + env: { + PATH: process.env.PATH, + GITHUB_API_URL: `http://127.0.0.1:${port}`, + GITHUB_REPOSITORY: "Afternote/Afternote-FE", + ...env, + }, + }).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error) => ({ code: error.code, stdout: error.stdout, stderr: error.stderr }), + ); + return { ...result, requests }; + } finally { + server.close(); + } +} + +test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨도 판정을 검증에 넘긴다", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "dependabot-pr-gate-")); + try { + const filledPath = path.join(directory, "filled.json"); + const filled = await runGateCli(["await", filledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90" }, + pullRequest: FILLED, + }); + assert.equal(filled.code, 0, filled.stderr); + assert.deepEqual(filled.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.deepEqual(JSON.parse(await readFile(filledPath, "utf8")), FILLED); + + const unfilledPath = path.join(directory, "unfilled.json"); + const unfilled = await runGateCli(["await", unfilledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "0" }, + pullRequest: UNFILLED, + }); + assert.equal(unfilled.code, 0, "시간 초과는 실패가 아니다. 검증이 평소 오류 문구로 판정한다"); + assert.match(unfilled.stdout, /::warning::dependabot-pr-gate 가 0초 안에 #2138/); + assert.deepEqual(JSON.parse(await readFile(unfilledPath, "utf8")), UNFILLED); + + const missing = await runGateCli(["await", path.join(directory, "x.json")], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138" }, + }); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /AWAIT_TIMEOUT_SECONDS/); + assert.deepEqual(missing.requests, [], "입력이 틀리면 API 를 부르지 않는다"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("모르는 명령은 채우기로 떨어지지 않는다", async () => { + const result = await runGateCli(["wait"], { env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" } }); + assert.equal(result.code, 1); + assert.match(result.stderr, /알 수 없는 명령: wait/); + assert.deepEqual(result.requests, [], "이슈 생성·PR 편집 요청이 나가지 않는다"); +}); + +test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot PR 에서 게이트를 기다린다 (#2212)", async () => { + const readWorkflow = (name) => readFile(new URL(`../workflows/${name}`, import.meta.url), "utf8"); + const gate = await readWorkflow("dependabot-pr-gate.yml"); + const repositoryQuality = await readWorkflow("repository-quality.yml"); + const managedDevice = await readWorkflow("android-managed-device.yml"); + const login = escapeRegExp(DEPENDABOT_LOGIN); + + // 같은 sha 의 dispatch 는 PR 롤업의 옛 실패를 못 덮고(#1825), 재실행은 옛 페이로드를 쓴다. + assert.doesNotMatch(gate, /^\s+actions:/m, "게이트에 actions 권한을 주지 않는다"); + assert.doesNotMatch(gate, /dispatches|rerun|workflow run/); + + // Repository Quality: pull_request 이면서 봇이 연 PR 만 live PR 을 기다렸다가 그 값으로 판정한다. + assert.match(repositoryQuality, /PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match( + repositoryQuality, + new RegExp( + `elif \\[ "\\$GITHUB_EVENT_NAME" = "pull_request" \\] && \\[ "\\$PR_AUTHOR" = "${login}" \\]; then\\n` + + `(?:\\s+#.*\\n)+` + + `\\s+AWAIT_TIMEOUT_SECONDS=(\\d+) node \\.github/scripts/dependabot-pr-gate\\.mjs await "\\$pull_request_file"\\n` + + `\\s+CHANGED_FILES=\\$\\(jq -r '\\.changed_files' "\\$pull_request_file"\\)`, + ), + ); + const awaitSeconds = Number(repositoryQuality.match(/AWAIT_TIMEOUT_SECONDS=(\d+) node/)[1]); + const quotaSeconds = Number(repositoryQuality.match(/ensure-api-quota\.mjs ensure --max-wait (\d+)/)[1]); + const jobMinutes = Number(repositoryQuality.match(/name: Repository Quality\n\s+runs-on: .+\n\s+timeout-minutes: (\d+)/)[1]); + assert.ok( + awaitSeconds + quotaSeconds + 30 <= jobMinutes * 60, + `게이트 대기 ${awaitSeconds}s + quota 대기 ${quotaSeconds}s 가 job timeout ${jobMinutes}분을 넘기면 판정 전에 취소된다`, + ); + + // Managed Device: 판정 재료는 신뢰 정책 사본으로 기다린 live PR 이다. PR 사본 스크립트를 부르지 않는다. + assert.match( + managedDevice, + /install -m 0644 \\\n\s+\.github\/scripts\/dependabot-pr-gate\.mjs \\\n\s+"\$policy_dir\/dependabot-pr-gate\.mjs"\n\s+dependabot_gate=trusted/, + ); + assert.match(managedDevice, /grep -q 'export async function awaitGate' \.github\/scripts\/dependabot-pr-gate\.mjs/); + assert.equal((managedDevice.match(/dependabot_gate=unavailable/g) ?? []).length, 2, "도입 PR·bootstrap 은 페이로드를 읽는다"); + assert.match(managedDevice, /EVENT_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match(managedDevice, /DEPENDABOT_GATE: \$\{\{ steps\.policy\.outputs\.dependabot_gate \}\}/); + assert.match( + managedDevice, + new RegExp(`if \\[\\[ "\\$EVENT_PR_AUTHOR" == "${login}" && "\\$DEPENDABOT_GATE" == "trusted" \\]\\]; then`), + ); + assert.match( + managedDevice, + /AWAIT_TIMEOUT_SECONDS=\d+ \\\n\s+node "\$RUNNER_TEMP\/android-test-policy\/dependabot-pr-gate\.mjs" await "\$pull_request_file"/, + ); + assert.match(managedDevice, /if \[\[ "\$live_head_sha" != "\$EVENT_PR_SHA" \]\]; then/); + assert.doesNotMatch(managedDevice, /node \.github\/scripts\/dependabot-pr-gate\.mjs/); +}); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index 7646189a31..651425bdf3 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -298,8 +298,21 @@ jobs: # tested revision의 권한 없는 classifier를 신뢰 경계 밖에서 실행하지 않는다. classifier=unavailable fi - printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\nsource=trusted\n' \ - "$renderer" "$classifier" "$expected_failures" >> "$GITHUB_OUTPUT" + # dependabot PR 은 게이트가 CI Test Plan 을 채울 때까지 이 신뢰 사본으로 기다린다(#2212). + # 파일 존재가 아니라 await 명령의 존재로 가른다. 이 파일은 #2112 부터 default branch 에 + # 있어서, 존재만 보면 도입 PR 이 명령 없는 옛 사본을 부른다. + if [[ -f .github/scripts/dependabot-pr-gate.mjs ]] && + grep -q 'export async function awaitGate' .github/scripts/dependabot-pr-gate.mjs; then + install -m 0644 \ + .github/scripts/dependabot-pr-gate.mjs \ + "$policy_dir/dependabot-pr-gate.mjs" + dependabot_gate=trusted + else + # 도입 PR 은 사람이 연다. 기다릴 게이트가 없으니 종전처럼 이벤트 페이로드를 읽는다. + dependabot_gate=unavailable + fi + printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\ndependabot_gate=%s\nsource=trusted\n' \ + "$renderer" "$classifier" "$expected_failures" "$dependabot_gate" >> "$GITHUB_OUTPUT" elif [[ ! -e .github/scripts/ci-test-plan.mjs && ! -e .github/scripts/resolve-android-test-plan.mjs && ! -e .github/scripts/resolve-pr-impact.mjs && @@ -312,6 +325,7 @@ jobs: renderer=bootstrap \ classifier=unavailable \ expected_failures=unavailable \ + dependabot_gate=unavailable \ source=bootstrap >> "$GITHUB_OUTPUT" else echo "::error::Android test policy가 부분 설치된 상태입니다" @@ -322,10 +336,12 @@ jobs: id: target env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DEPENDABOT_GATE: ${{ steps.policy.outputs.dependabot_gate }} DISPATCH_REF_NAME: ${{ github.ref_name }} DISPATCH_PR_NUMBER: ${{ inputs.pull_request_number || 0 }} DEVICE: ${{ matrix.device }} EVENT_NAME: ${{ github.event_name }} + EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }} EVENT_PR_BRANCH: ${{ github.event.pull_request.head.ref }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} EVENT_PR_SHA: ${{ github.event.pull_request.head.sha }} @@ -344,7 +360,21 @@ jobs: target_pr="$EVENT_PR_NUMBER" target_branch="$EVENT_PR_BRANCH" target_sha="$EVENT_PR_SHA" - jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + if [[ "$EVENT_PR_AUTHOR" == "dependabot[bot]" && "$DEPENDABOT_GATE" == "trusted" ]]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 CI Test Plan 을 채운다. 그 edited 는 + # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이라, 게이트가 채울 때까지 + # live PR 을 기다린다(#2212). 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 + # 인프라 복구를 잃는다. lane timeout 이 15분 이상이라 3분까지 기다린다. + PR_NUMBER="$EVENT_PR_NUMBER" AWAIT_TIMEOUT_SECONDS=180 \ + node "$RUNNER_TEMP/android-test-policy/dependabot-pr-gate.mjs" await "$pull_request_file" + live_head_sha="$(jq -r '.head.sha' "$pull_request_file")" + if [[ "$live_head_sha" != "$EVENT_PR_SHA" ]]; then + echo "::error::기다리는 사이 PR head 가 $EVENT_PR_SHA 에서 $live_head_sha 로 움직였습니다. 새 head 의 run 이 판정합니다" + exit 1 + fi + else + jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + fi elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_PR_NUMBER" != "0" ]]; then if ! [[ "$DISPATCH_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then echo "::error::유효한 pull_request_number 가 필요합니다" diff --git a/.github/workflows/dependabot-pr-gate.yml b/.github/workflows/dependabot-pr-gate.yml index cd5e362f72..9e29ba67f8 100644 --- a/.github/workflows/dependabot-pr-gate.yml +++ b/.github/workflows/dependabot-pr-gate.yml @@ -6,6 +6,11 @@ # pull_request_target 인 이유: dependabot 이 연 pull_request 이벤트의 토큰은 읽기 전용이라 # 이슈 생성·PR 편집을 못 한다. default branch 의 워크플로 정의만 실행되고, PR 코드는 # 체크아웃하지도 실행하지도 않는다. default branch 의 정책 스크립트와 GitHub API 응답만 쓴다. +# +# 이 편집이 낸 edited 는 GITHUB_TOKEN 이라 PR Validation·Managed Device 를 다시 깨우지 못한다. +# 여기서 그 둘을 dispatch·재실행하지 않고 actions 권한도 두지 않는다. 같은 sha 의 dispatch 초록은 +# PR 롤업이 옛 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 쓴다. 대신 두 검증이 dependabot +# PR 에서만 이 게이트가 채울 때까지 live PR 을 기다린다(#2212, dependabot-pr-gate.mjs await). name: dependabot-pr-gate on: diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 2c173a018d..05c8058e89 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -118,6 +118,7 @@ jobs: CHANGED_FILES: ${{ github.event.pull_request.changed_files }} GH_TOKEN: ${{ github.token }} MAX_PATH_FILTER_FILES: 300 + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ inputs.pull_request_number }} run: | set -euo pipefail @@ -138,6 +139,13 @@ jobs: fi CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") printf '%s' "$pull_request" > "$pull_request_file" + elif [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$PR_AUTHOR" = "dependabot[bot]" ]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Refs·CI Test Plan 을 채운다. + # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이라, 게이트가 + # 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다(#2212). quota 대기 60초와 합쳐도 + # job timeout 3분 안에 들도록 90초에서 끊는다. + AWAIT_TIMEOUT_SECONDS=90 node .github/scripts/dependabot-pr-gate.mjs await "$pull_request_file" + CHANGED_FILES=$(jq -r '.changed_files' "$pull_request_file") elif [ -z "$CHANGED_FILES" ]; then pull_request=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") From 4f988ce38cbfae7c7076de00760a6149ef3b70e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 18:52:56 +0900 Subject: [PATCH 039/116] =?UTF-8?q?fix(platform):=20=EC=B1=84=EC=9B=8C?= =?UTF-8?q?=EC=A7=84=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=EB=8A=94=20API?= =?UTF-8?q?=20=EC=97=86=EC=9D=B4=20=EC=93=B0=EA=B3=A0,=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=EC=9D=B4=20=EC=B1=84=EC=9A=B4=20dependabot=20PR=20?= =?UTF-8?q?=EC=9D=80=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#2212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 나온 지적을 반영한다. 게이트가 채운 뒤의 edited·synchronize 까지 기기 레인이 매번 API 를 불러 일시 오류 하나에 required lane 이 red 가 될 수 있었다. 이벤트 페이로드 제목에 대표 이슈 번호가 이미 있으면 종전처럼 페이로드를 쓴다. 대기 종료는 게이트 마커가 아니라 제목의 대표 이슈 번호로 가른다. 조회 오류는 제한 시간 안에서 다시 시도한다. 스테이징 판별 grep 문자열이 스크립트에 실제로 있는지, 인자 없는 호출이 채우기인지 테스트로 잠근다. --- .github/scripts/dependabot-pr-gate.mjs | 69 +++++++++++---- .github/scripts/dependabot-pr-gate.test.mjs | 89 ++++++++++++++++++-- .github/workflows/android-managed-device.yml | 7 +- .github/workflows/repository-quality.yml | 7 +- 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index e520bf81a5..63548b51b2 100644 --- a/.github/scripts/dependabot-pr-gate.mjs +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -13,10 +13,10 @@ // 1회차 인프라 복구를 잃으며, App 토큰은 이 브리지에 금지된 시크릿이 필요하다. 그래서 방향을 // 뒤집었다. 검증이 `await` 명령으로 게이트가 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다. // -// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출(main)을 -// 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. +// 순수 함수(needsGate·awaitsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출 +// (fillGate·awaitMain)을 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -66,23 +66,50 @@ export function buildPlan(changedPaths) { export const AWAIT_POLL_INTERVAL_MS = 5_000; /** - * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). dependabot 이 연 열린 PR 이 - * 아니거나 이미 채워졌으면 첫 조회 결과를 바로 돌려준다. 제한 시간이 지나면 기다림을 끝내고 마지막 - * live 상태를 돌려준다. 판정은 호출한 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 - * 실패한다. + * 검증이 아직 기다려야 하는 PR 인가. dependabot 이 연 열린 PR 인데 제목에 대표 이슈 번호가 없을 때만이다. + * 마커가 아니라 제목으로 가른다. 게이트가 경계 경로에서 멈춰 사람이 손으로 채운 PR 에는 마커가 없는데, + * 그 PR 까지 edited 마다 제한 시간을 꽉 채워 기다리게 하지 않는다. 게이트는 제목과 본문을 한 번의 + * PATCH 로 바꾸므로 제목에 번호가 있으면 본문도 채워져 있다. */ -export async function awaitGate({ loadPullRequest, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { +export function awaitsGate(pullRequest) { + return ( + pullRequest?.user?.login === DEPENDABOT_LOGIN && + pullRequest?.state === "open" && + !TITLE_ISSUE_PATTERN.test(String(pullRequest?.title ?? "")) + ); +} + +/** + * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). `initial`(이벤트 페이로드의 PR)이 이미 + * 판정할 수 있는 상태면 API 를 부르지 않고 그대로 돌려준다. 게이트가 채운 뒤의 edited·synchronize 는 + * 종전처럼 페이로드만으로 끝난다. 조회가 실패하면 제한 시간 안에서 다시 시도한다. 한 번도 읽지 못한 채 + * 시간이 다하면 마지막 오류를 던지고, 읽은 적이 있으면 마지막 live 상태를 돌려준다. 판정은 호출한 + * 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 실패한다. + */ +export async function awaitGate({ loadPullRequest, initial, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { + if (initial && !awaitsGate(initial)) { + return { pullRequest: initial, polls: 0, timedOut: false }; + } const deadline = Date.now() + timeoutMs; + let latest; + let lastError; for (let polls = 1; ; polls += 1) { - const pullRequest = await loadPullRequest(); - const waiting = - pullRequest?.user?.login === DEPENDABOT_LOGIN && pullRequest?.state === "open" && needsGate(pullRequest); - if (!waiting) { - return { pullRequest, polls, timedOut: false }; + try { + const pullRequest = await loadPullRequest(); + if (!awaitsGate(pullRequest)) { + return { pullRequest, polls, timedOut: false }; + } + latest = pullRequest; + lastError = undefined; + } catch (error) { + lastError = error; } const remaining = deadline - Date.now(); if (remaining <= 0) { - return { pullRequest, polls, timedOut: true }; + if (latest === undefined) { + throw lastError; + } + return { pullRequest: latest, polls, timedOut: true, lastError }; } await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining))); } @@ -236,8 +263,9 @@ async function fillGate() { } /** - * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 live PR JSON 을 - * 출력 경로에 쓴다. 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 PR JSON 을 출력 + * 경로에 쓴다. 이벤트 페이로드(GITHUB_EVENT_PATH)의 PR 이 이미 판정할 수 있는 상태면 그것을 그대로 쓴다. + * 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. */ async function awaitMain(outputPath) { const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; @@ -252,15 +280,20 @@ async function awaitMain(outputPath) { throw new Error(`AWAIT_TIMEOUT_SECONDS 는 0 이상의 정수여야 합니다: ${process.env.AWAIT_TIMEOUT_SECONDS}`); } - const { pullRequest, polls, timedOut } = await awaitGate({ + const eventPullRequest = process.env.GITHUB_EVENT_PATH + ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")).pull_request + : undefined; + const { pullRequest, polls, timedOut, lastError } = await awaitGate({ loadPullRequest: () => github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token), + initial: eventPullRequest?.number === prNumber ? eventPullRequest : undefined, timeoutMs: timeoutSeconds * 1000, }); await writeFile(outputPath, JSON.stringify(pullRequest)); if (timedOut) { console.log( `::warning::dependabot-pr-gate 가 ${timeoutSeconds}초 안에 #${prNumber} 의 제목·본문을 채우지 않았습니다. ` + - "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다.", + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다." + + (lastError ? ` 마지막 조회 오류: ${lastError.message}` : ""), ); } else if (polls > 1) { console.log(`#${prNumber} 게이트가 채운 제목·본문을 ${polls}번째 조회에서 확인`); diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs index 3f75adcd1c..aa6370ea47 100644 --- a/.github/scripts/dependabot-pr-gate.test.mjs +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -16,6 +16,7 @@ import { ISSUE_LABELS, applyGate, awaitGate, + awaitsGate, buildIssue, buildPlan, needsGate, @@ -110,7 +111,7 @@ test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않 assert.match(source, /^permissions: \{\}/m); assert.match(source, /issues: write/); assert.match(source, /pull-requests: write/); - assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); + assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs$/m, "인자 없이 불러야 채우기다"); }); const FILLED = { @@ -142,8 +143,12 @@ test("await 는 게이트가 채운 뒤의 live PR 을 돌려준다 (#2212)", as }); test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () => { + const handFilled = { ...UNFILLED, title: `${BOT_TITLE} (#2139)`, body: "Refs #2139\n\n## CI Test Plan\n..." }; + assert.equal(needsGate(handFilled), true, "마커가 없어 게이트는 다시 채우려 한다"); + assert.equal(awaitsGate(handFilled), false, "검증은 사람이 채운 PR 을 기다리지 않는다"); for (const pullRequest of [ FILLED, + handFilled, { ...UNFILLED, user: { login: "1hyok" } }, { ...UNFILLED, state: "closed" }, ]) { @@ -155,6 +160,48 @@ test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () } }); +test("await 는 페이로드가 이미 판정할 수 있으면 API 를 부르지 않는다", async () => { + const live = sequence(UNFILLED); + const ready = await awaitGate({ loadPullRequest: live.load, initial: FILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 0, "게이트가 채운 뒤의 edited·synchronize 는 종전처럼 페이로드로 끝난다"); + assert.equal(ready.pullRequest, FILLED); + assert.equal(ready.polls, 0); + + const stale = sequence(UNFILLED, FILLED); + const waited = await awaitGate({ loadPullRequest: stale.load, initial: UNFILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(stale.calls(), 2, "opened 페이로드는 옛 값이라 live 를 기다린다"); + assert.equal(waited.pullRequest, FILLED); +}); + +test("await 는 조회 오류를 제한 시간 안에서 다시 시도하고, 한 번도 못 읽으면 오류를 던진다", async () => { + let calls = 0; + const flaky = async () => { + calls += 1; + if (calls === 1) throw new Error("GET pulls -> 502"); + return calls === 2 ? UNFILLED : FILLED; + }; + const recovered = await awaitGate({ loadPullRequest: flaky, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(recovered.timedOut, false); + assert.equal(recovered.pullRequest, FILLED); + assert.equal(calls, 3); + + const down = async () => { + throw new Error("GET pulls -> 403 rate limit"); + }; + await assert.rejects(awaitGate({ loadPullRequest: down, timeoutMs: 30, intervalMs: 5 }), /403 rate limit/); + + let readOnce = 0; + const thenDown = async () => { + readOnce += 1; + if (readOnce === 1) return UNFILLED; + throw new Error("GET pulls -> 502"); + }; + const partial = await awaitGate({ loadPullRequest: thenDown, timeoutMs: 30, intervalMs: 5 }); + assert.equal(partial.timedOut, true); + assert.equal(partial.pullRequest, UNFILLED, "읽은 적이 있으면 마지막 live 상태를 넘긴다"); + assert.match(partial.lastError.message, /502/); +}); + test("await 는 제한 시간이 지나면 기다림을 끝내고 마지막 live 상태를 넘긴다", async () => { const live = sequence(UNFILLED); const started = Date.now(); @@ -219,6 +266,25 @@ test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨 assert.match(unfilled.stdout, /::warning::dependabot-pr-gate 가 0초 안에 #2138/); assert.deepEqual(JSON.parse(await readFile(unfilledPath, "utf8")), UNFILLED); + const eventPath = path.join(directory, "event.json"); + await writeFile(eventPath, JSON.stringify({ action: "synchronize", pull_request: { ...FILLED, number: 2138 } })); + const payloadPath = path.join(directory, "payload.json"); + const fromPayload = await runGateCli(["await", payloadPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90", GITHUB_EVENT_PATH: eventPath }, + pullRequest: UNFILLED, + }); + assert.equal(fromPayload.code, 0, fromPayload.stderr); + assert.deepEqual(fromPayload.requests, [], "채워진 페이로드면 API 를 부르지 않는다"); + assert.deepEqual(JSON.parse(await readFile(payloadPath, "utf8")), { ...FILLED, number: 2138 }); + + const otherPath = path.join(directory, "other.json"); + const otherPullRequest = await runGateCli(["await", otherPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2140", AWAIT_TIMEOUT_SECONDS: "0", GITHUB_EVENT_PATH: eventPath }, + pullRequest: FILLED, + }); + assert.equal(otherPullRequest.code, 0, otherPullRequest.stderr); + assert.deepEqual(otherPullRequest.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2140"], "다른 PR 의 페이로드는 쓰지 않는다"); + const missing = await runGateCli(["await", path.join(directory, "x.json")], { env: { GH_TOKEN: "t", PR_NUMBER: "2138" }, }); @@ -230,6 +296,16 @@ test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨 } }); +test("인자 없이 부르면 채우기다: live PR 을 읽고 봇 PR 이 아니면 아무것도 쓰지 않는다", async () => { + const result = await runGateCli([], { + env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" }, + pullRequest: { user: { login: "1hyok" }, state: "open", title: "x", body: "" }, + }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(result.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.match(result.stdout, /작성자가 1hyok 이라 건너뜀/); +}); + test("모르는 명령은 채우기로 떨어지지 않는다", async () => { const result = await runGateCli(["wait"], { env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" } }); assert.equal(result.code, 1); @@ -246,7 +322,6 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P // 같은 sha 의 dispatch 는 PR 롤업의 옛 실패를 못 덮고(#1825), 재실행은 옛 페이로드를 쓴다. assert.doesNotMatch(gate, /^\s+actions:/m, "게이트에 actions 권한을 주지 않는다"); - assert.doesNotMatch(gate, /dispatches|rerun|workflow run/); // Repository Quality: pull_request 이면서 봇이 연 PR 만 live PR 을 기다렸다가 그 값으로 판정한다. assert.match(repositoryQuality, /PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); @@ -254,7 +329,7 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P repositoryQuality, new RegExp( `elif \\[ "\\$GITHUB_EVENT_NAME" = "pull_request" \\] && \\[ "\\$PR_AUTHOR" = "${login}" \\]; then\\n` + - `(?:\\s+#.*\\n)+` + + `(?:\\s+#.*\\n)*` + `\\s+AWAIT_TIMEOUT_SECONDS=(\\d+) node \\.github/scripts/dependabot-pr-gate\\.mjs await "\\$pull_request_file"\\n` + `\\s+CHANGED_FILES=\\$\\(jq -r '\\.changed_files' "\\$pull_request_file"\\)`, ), @@ -272,7 +347,11 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P managedDevice, /install -m 0644 \\\n\s+\.github\/scripts\/dependabot-pr-gate\.mjs \\\n\s+"\$policy_dir\/dependabot-pr-gate\.mjs"\n\s+dependabot_gate=trusted/, ); - assert.match(managedDevice, /grep -q 'export async function awaitGate' \.github\/scripts\/dependabot-pr-gate\.mjs/); + // 신뢰 사본에 대기 명령이 있는지를 이 문자열로 가른다. 스크립트가 선언 모양을 바꾸면 기기 레인 + // 대기가 조용히 꺼지므로, grep 문자열이 스크립트에 실제로 있는지 대조한다. + const probe = managedDevice.match(/grep -q '([^']+)' \.github\/scripts\/dependabot-pr-gate\.mjs/)?.[1]; + assert.ok(probe, "스테이징 판별 grep 이 없다"); + assert.ok((await readFile(GATE_SCRIPT, "utf8")).includes(probe), `dependabot-pr-gate.mjs 에 '${probe}' 가 없다`); assert.equal((managedDevice.match(/dependabot_gate=unavailable/g) ?? []).length, 2, "도입 PR·bootstrap 은 페이로드를 읽는다"); assert.match(managedDevice, /EVENT_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); assert.match(managedDevice, /DEPENDABOT_GATE: \$\{\{ steps\.policy\.outputs\.dependabot_gate \}\}/); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index 651425bdf3..aeea5b05c7 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -362,9 +362,10 @@ jobs: target_sha="$EVENT_PR_SHA" if [[ "$EVENT_PR_AUTHOR" == "dependabot[bot]" && "$DEPENDABOT_GATE" == "trusted" ]]; then # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 CI Test Plan 을 채운다. 그 edited 는 - # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이라, 게이트가 채울 때까지 - # live PR 을 기다린다(#2212). 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 - # 인프라 복구를 잃는다. lane timeout 이 15분 이상이라 3분까지 기다린다. + # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). 페이로드 제목에 + # 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 때까지 live PR 을 + # 기다린다. 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 인프라 복구를 잃는다. + # lane timeout 이 15분 이상이라 3분까지 기다린다. PR_NUMBER="$EVENT_PR_NUMBER" AWAIT_TIMEOUT_SECONDS=180 \ node "$RUNNER_TEMP/android-test-policy/dependabot-pr-gate.mjs" await "$pull_request_file" live_head_sha="$(jq -r '.head.sha' "$pull_request_file")" diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 05c8058e89..e333f82672 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -141,9 +141,10 @@ jobs: printf '%s' "$pull_request" > "$pull_request_file" elif [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$PR_AUTHOR" = "dependabot[bot]" ]; then # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Refs·CI Test Plan 을 채운다. - # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이라, 게이트가 - # 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다(#2212). quota 대기 60초와 합쳐도 - # job timeout 3분 안에 들도록 90초에서 끊는다. + # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). + # 페이로드 제목에 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 + # 때까지 live PR 을 기다렸다가 그 값으로 판정한다. quota 대기 60초와 합쳐도 job timeout + # 3분 안에 들도록 90초에서 끊는다. AWAIT_TIMEOUT_SECONDS=90 node .github/scripts/dependabot-pr-gate.mjs await "$pull_request_file" CHANGED_FILES=$(jq -r '.changed_files' "$pull_request_file") elif [ -z "$CHANGED_FILES" ]; then From 07ba9e718def4923269babe446cd9278794c2983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 30 Sep 2026 20:25:31 +0900 Subject: [PATCH 040/116] =?UTF-8?q?refactor(setting):=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=8A=94=20ObserveFlag,=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=EB=8A=94=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전달 조건 저장 완료 신호를 Unit? 대신 isSaved 플래그로 두고 core:ui ObserveFlag 로 소비한다. - 전달 조건·수신인 편집 ViewModel 의 assisted route 근거 KDoc(#1695)을 되살린다. - 수신인 편집의 이메일·전화번호 형식 검증 실패를 SaveFailed 가 아니라 ValidationFailed 로 보낸다. --- .../presentation/delivery/DeliveryConditionScreen.kt | 8 ++++---- .../presentation/delivery/DeliveryConditionUiState.kt | 2 +- .../delivery/DeliveryConditionViewModel.kt | 10 +++++++--- .../presentation/receiver/ReceiverEditContract.kt | 5 +++++ .../presentation/receiver/ReceiverEditViewModel.kt | 9 +++++++-- .../setting/presentation/SettingCompletionTest.kt | 4 ++-- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 62aad55e42..8e3595cd72 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.delivery.DeliveryConditionType -import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.core.ui.mvi.ObserveFlag import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R @@ -41,11 +41,11 @@ internal fun DeliveryConditionScreen( viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - ObserveSignal( - signal = uiState.pendingEvent, + ObserveFlag( + raised = uiState.isSaved, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, - onSignal = { onSaveSuccess() }, + onRaised = onSaveSuccess, ) DeliveryConditionContent( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index d3fc8aa938..7ba60d8f0e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -14,7 +14,7 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, - val pendingEvent: Unit? = null, + val isSaved: Boolean = false, ) : UiState enum class DeliveryConditionError { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index b6ea20b5cd..838fb5f4c6 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -16,6 +16,10 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +/** + * 대상 수신자 [SettingRoute.AfterDeliveryRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ @HiltViewModel(assistedFactory = DeliveryConditionViewModel.Factory::class) internal class DeliveryConditionViewModel @AssistedInject @@ -66,7 +70,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, pendingEvent = Unit) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -74,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(pendingEvent = null) + state.copy(isSaved = false) } } @@ -102,7 +106,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving || state.pendingEvent != null) return + if (!state.isInitialized || state.isSaving || state.isSaved) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt index 6127d5ad44..d3d586e4bc 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContract.kt @@ -26,6 +26,11 @@ internal sealed interface ReceiverEditReducerEvent : ReducerEvent { val message: UiText, ) : ReceiverEditReducerEvent + /** 요청을 보내기 전의 입력 형식 검증 실패. 서버 저장 실패([SaveFailed])와 구분한다. */ + data class ValidationFailed( + val message: UiText, + ) : ReceiverEditReducerEvent + data object Saving : ReceiverEditReducerEvent data object Saved : ReceiverEditReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 0e377b8506..55e922dd8b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -13,6 +13,10 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +/** + * 수정 대상 [SettingRoute.RecipientEditRoute] 는 assisted 로 받는다 — Nav3 entry 에는 Nav2 의 + * `savedStateHandle.toRoute()` 자동 채움이 없다 (#1695). + */ @HiltViewModel(assistedFactory = ReceiverEditViewModel.Factory::class) internal class ReceiverEditViewModel @AssistedInject @@ -36,6 +40,7 @@ internal class ReceiverEditViewModel when (event) { is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) ReceiverEditReducerEvent.Saved -> state.copy(isSaving = false, pendingEvent = ReceiverEditEvent.EditSuccess) is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) @@ -66,7 +71,7 @@ internal class ReceiverEditViewModel ) { if (currentState.isSaving || currentState.pendingEvent != null) return if (!email.isValidReceiverEmail()) { - dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) + dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return } val phoneValidation = phone.validateReceiverPhone(isRequired = true) @@ -77,7 +82,7 @@ internal class ReceiverEditViewModel } else { R.string.setting_receiver_phone_invalid } - dispatch(ReceiverEditReducerEvent.SaveFailed(UiText.Resource(messageRes))) + dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(messageRes))) return } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index cecd2b42b9..f45105d4c8 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -597,9 +597,9 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.pendingEvent != null } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaved } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertEquals(null, viewModel.uiState.value.pendingEvent) + assertFalse(viewModel.uiState.value.isSaved) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } From 52f25816bd148297c3b8d62533b3c390fe8c770d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 00:54:51 +0900 Subject: [PATCH 041/116] =?UTF-8?q?fix(build):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=8F=84=EA=B5=AC=EC=9D=98=20=EC=B7=A8=EC=95=BD=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B2=84=EC=A0=84=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C=20(#2219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-logic/build.gradle.kts | 6 ++++++ build.gradle.kts | 18 ++++++++++++++++++ gradle/libs.versions.toml | 3 +++ 3 files changed, 27 insertions(+) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 938128259b..d2fe377735 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -30,6 +30,12 @@ dependencies { // 루트 build.gradle.kts 의 보안 하한은 별도 빌드인 여기까지 미치지 않는다 — 같은 근거(#2132·#981· // #982·#985). AGP 9.3.2 이 이 클래스패스에도 같은 취약 버전을 끌어온다(netty 는 여기 없다). constraints { + // 별도 빌드의 ktlint 는 루트 보안 하한을 상속하지 않는다. 실제 CLI 클래스패스에만 적용한다. + listOf("logback-core", "logback-classic").forEach { artifact -> + add("ktlint", "ch.qos.logback:$artifact:${libs.versions.logback.get()}") { + because("GHSA-jhq6-gfmj-v8fx 등 3건: ktlint-cli 1.8.0 경유 1.3.16 취약, #2219") + } + } listOf("bcprov-jdk18on", "bcpkix-jdk18on", "bcutil-jdk18on").forEach { artifact -> implementation("org.bouncycastle:$artifact:${libs.versions.bouncycastle.get()}") { because("GHSA-9pwp-9qqc-pr26·GHSA-qp49-qgx5-5m26 — 1.85 미만 취약 — #2132") diff --git a/build.gradle.kts b/build.gradle.kts index 42d07c9c9e..86f64586e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -125,7 +125,25 @@ val securityFloors = "GHSA-4gg5-vx3j-xwc7·GHSA-735f-pc8j-v9w8 — " + "Accessibility Test Framework 4.1.1 경유 3.19.1 잔존 — #1262", ), + SecurityFloor( + module = "com.google.guava:guava", + version = libs.versions.guavaAndroid.get(), + because = "GHSA-7g45-4rm6-3mm3·GHSA-5mg8-w23w-74h3: 접근성 테스트의 31.0.1-android 취약, #2219", + ), + SecurityFloor( + module = "org.jsoup:jsoup", + version = libs.versions.jsoup.get(), + because = "GHSA-pmhh-3w7g-xqp8·GHSA-gp7f-rwcx-9369: 접근성 테스트의 1.15.1 취약, #2219", + ), ) + + // baselineprofile 테스트 모듈의 디코더만 올린다. constraint 는 새 런타임 의존성을 추가하지 않는다. + listOf("wire-runtime", "wire-runtime-jvm").map { artifact -> + SecurityFloor( + module = "com.squareup.wire:$artifact", + version = libs.versions.wire.get(), + because = "GHSA-9rm7-3qhh-h2mc: benchmark 1.5.0-rc02 경유 6.4.0 취약, #2219", + ) + } + // logback-core 만 취약하지만 classic 은 core 와 같은 버전이라야 동작해 함께 올린다. listOf("logback-core", "logback-classic").map { artifact -> SecurityFloor( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c270032fe7..200cfdd31f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,9 @@ httpclient = "4.5.14" logback = "1.5.34" testcontainers = "2.0.5" protobufJavalite = "3.25.9" +guavaAndroid = "32.0.1-android" +jsoup = "1.23.1" +wire = "6.4.5" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } From 07d30f9c01d8abb9c5613cae1b1e92a1524f9f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 00:57:16 +0900 Subject: [PATCH 042/116] =?UTF-8?q?test(setting):=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=80=EC=9E=A5=20=EC=8B=A0=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=EC=8A=A4=ED=83=9D=EC=9D=84=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=EB=A7=8C=20=EB=82=B4=EB=A6=AC=EB=8A=94=EC=A7=80=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 수정의 pendingEvent 와 전달 조건의 isSaved 가 화면에서 한 번만 소비되는지는 ViewModel 단위 테스트로 보이지 않는다. 실제 설정 로컬 스택을 띄워 저장 성공은 정확히 한 칸 내려오고, 저장 실패와 잘못된 이메일·연락처는 화면을 닫지 않고 요청도 보내지 않는지 잰다. 성공 콜백을 두 번 부르게 바꾸면 성공 케이스 두 건이 실패하는 것을 로컬에서 확인했다. --- .../SettingEditorSaveSignalAndroidTest.kt | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt new file mode 100644 index 0000000000..410d021b9f --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -0,0 +1,236 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import javax.inject.Inject + +/** + * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). + * + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaved`)가 화면에서 한 번만 + * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 + * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 + * ViewModel 단위 테스트로는 보이지 않는 자리다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SettingEditorSaveSignalAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + @Inject + lateinit var userRepository: UserRepository + + private val fakeUserRepository: FakeUserRepository + get() = userRepository as FakeUserRepository + + /** 로컬 스택 바닥에서 셸로 나간 횟수 — 설정 안에서 도는 동안은 0 이어야 한다. */ + private var exits = 0 + + @Before + fun setUp() { + hiltRule.inject() + fakeUserRepository.receiverDetails[RECEIVER_ID] = + ReceiverDetail( + receiverId = RECEIVER_ID, + name = RECEIVER_NAME, + relation = "가족", + phone = "01012345678", + email = "kim@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = "잘 지내", + ) + fakeUserRepository.onGetReceiverDetail = null + } + + @Test + fun receiverEdit_saveSuccessPopsExactlyOneScreenToReceiverList() { + fakeUserRepository.onUpdateReceiver = null + fakeUserRepository.onUpdateReceiverMessage = null + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNode(hasSetTextAction() and hasText(RECEIVER_NAME)).performTextReplacement("김수정") + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled().performClick() + + waitUntilGone(EDIT_TITLE) + // 한 칸만 내려와 목록이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onNodeWithText("김수정").assertIsDisplayed() + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(1, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_saveFailureShowsMessageAndStaysOnEdit() { + fakeUserRepository.onUpdateReceiver = { _, _, _, _, _ -> throw IOException("offline") } + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNodeWithText(SAVE_ACTION).performClick() + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(EDIT_FAILED_MESSAGE)).fetchSemanticsNodes().isNotEmpty() + } + // 실패는 화면을 닫지 않고, 저장 중 잠금도 풀려 다시 누를 수 있다. + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled() + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(0, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_invalidEmailOrPhoneBlocksSaveWithoutRequest() { + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote.local")).performTextReplacement("kim@afternote") + composeRule.onNodeWithText("올바른 이메일 주소를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote")).performTextReplacement("kim@afternote.local") + composeRule.onNode(hasSetTextAction() and hasText("010", substring = true)).performTextReplacement("0101234") + composeRule.onNodeWithText("올바른 연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + assertEquals(0, composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size - updatesBefore }) + assertEquals(0, exits) + } + + @Test + fun deliveryCondition_saveSuccessPopsExactlyOneScreenToReceiverSelection() { + fakeUserRepository.onGetReceiverDeliveryConditions = null + fakeUserRepository.onUpdateReceiverDeliveryConditions = null + launchHost() + waitForSettingHomeContent() + val updatesBefore = fakeUserRepository.deliveryUpdateCalls.size + composeRule.onNodeWithText("사후 전달 조건").performScrollTo().performClick() + composeRule.onAllNodes(checkboxMatcher).run { + assertCountEquals(1) + get(0).performClick() + } + composeRule.onNodeWithText(RECEIVER_SELECT_DONE).performClick() + + // 조회가 끝나야 저장이 열린다. + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(DELIVERY_SAVE) and isEnabled()).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(DELIVERY_SAVE).performClick() + + waitUntilShown(RECEIVER_SELECT_DONE) + // 한 칸만 내려와 수신자 선택이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.deliveryUpdateCalls.size - updatesBefore) + assertEquals(RECEIVER_ID, fakeUserRepository.deliveryUpdateCalls[updatesBefore].receiverId) + assertEquals(0, exits) + } + + /** 설정 홈 → 수신자 목록 → 김수신 행으로 수정 화면을 연다. 프리필이 끝나야 폼이 그려진다. */ + private fun openReceiverEdit() { + launchHost() + waitForSettingHomeContent() + composeRule.onAllNodes(hasText("수신자 목록")).run { + assertCountEquals(2) + get(1).performScrollTo().performClick() + } + composeRule.onNodeWithText(RECEIVER_NAME).performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasSetTextAction() and hasText(RECEIVER_NAME)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun launchHost() { + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + startWithRecipientRegistration = false, + ) + } + } + } + } + + private fun waitForSettingHomeContent() = waitUntilShown("프로필 수정") + + private fun waitUntilShown(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitUntilGone(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isEmpty() + } + } + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + const val RECEIVER_ID = 7L + const val RECEIVER_NAME = "김수신" + const val EDIT_TITLE = "수신자 수정" + const val SAVE_ACTION = "수정" + const val EDIT_FAILED_MESSAGE = "수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요." + const val DELIVERY_SAVE = "저장" + const val RECEIVER_SELECT_DONE = "수신자 선택 완료하기" + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} From 251b7eb03dedcaef2dffa389a98c2662a93db9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 01:03:25 +0900 Subject: [PATCH 043/116] =?UTF-8?q?chore(build):=20=EB=B3=91=EB=A0=AC=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=8C=A8=EC=B9=98=20=EA=B0=84=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80=20(#2219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 200cfdd31f..6b915e0db2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,9 @@ profileInstaller = "1.4.1" # 루트 build.gradle.kts 의 buildscript 블록은 카탈로그 accessor 를 못 써 같은 값을 리터럴로 둔다. bouncycastle = "1.85" netty = "4.1.137.Final" +guavaAndroid = "32.0.1-android" +jsoup = "1.23.1" +wire = "6.4.5" commonsLang3 = "3.18.0" jose4j = "0.9.6" jdom2 = "2.0.6.1" @@ -64,9 +67,6 @@ httpclient = "4.5.14" logback = "1.5.34" testcontainers = "2.0.5" protobufJavalite = "3.25.9" -guavaAndroid = "32.0.1-android" -jsoup = "1.23.1" -wire = "6.4.5" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } From d138c4fa7b2ef501bbce87b4c7a985db68091834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:11:15 +0900 Subject: [PATCH 044/116] test(setting): cover profile save back and picker restoration (#1438) --- .../ProfileSaveBackAndroidTest.kt | 120 ++++++++++++ .../ProfileEditActivityRestorationTest.kt | 182 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt new file mode 100644 index 0000000000..6f06072638 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt @@ -0,0 +1,120 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** #1438: 실제 SettingNavHost의 entry 이탈이 보류 중 저장을 취소하고 새 entry를 만드는지 검증한다. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ProfileSaveBackAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Inject + lateinit var profileRepository: MyProfileRepository + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun systemBackDuringPendingSaveCancelsRequestAndReentryLoadsServerProfile() { + // 기존 app Hilt fixture가 제공하는 동일 저장소의 응답만 보류한다. 프로덕션 nav/VM은 그대로다. + val repository = profileRepository as FakeUserRepository + val saved = User("서버 이름", "qa@example.test", "01000000000", null) + repository.profile = saved + val pendingSave = CompletableDeferred() + val cancelled = CompletableDeferred() + var exits = 0 + repository.onUpdateMyProfile = { _, _, _ -> + try { + pendingSave.await() + } catch (error: CancellationException) { + cancelled.complete(Unit) + throw error + } + } + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + ) + } + } + } + waitForText("프로필 수정") + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("프로필 설정") + composeRule.onAllNodes(hasSetTextAction())[0].performTextReplacement("저장 중 임시 이름") + composeRule.onNodeWithText("수정하기").performScrollTo().performClick() + composeRule.waitUntil(10_000) { repository.profileUpdateCalls.size == 1 } + composeRule.onNodeWithText("수정하기").assertIsNotEnabled() + assertFalse(pendingSave.isCompleted) + + // Activity의 실제 back dispatcher가 SettingNavHost/NavDisplay의 back 처리를 실행한다. + composeRule.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + waitForText("프로필 수정") + composeRule.waitUntil(10_000) { cancelled.isCompleted } + assertEquals(0, exits) + assertEquals(saved, repository.profile) + + val loadsBeforeReentry = repository.profileCalls + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("서버 이름") + composeRule.waitUntil(10_000) { repository.profileCalls > loadsBeforeReentry } + composeRule.onNodeWithText("수정하기").performScrollTo().assertIsEnabled() + + // 취소 뒤 늦은 성공도 현재 entry를 닫거나 서버 fake의 정본을 바꾸지 않는다. + pendingSave.complete(saved.copy(name = "저장 중 임시 이름")) + composeRule.waitForIdle() + composeRule.onNodeWithText("프로필 설정").assertIsDisplayed() + assertEquals(saved, repository.profile) + assertEquals(1, repository.profileUpdateCalls.size) + assertTrue(cancelled.isCompleted) + assertEquals(0, exits) + } + + private fun waitForText(text: String) { + composeRule.waitUntil(10_000) { composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt new file mode 100644 index 0000000000..4a16576706 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditActivityRestorationTest.kt @@ -0,0 +1,182 @@ +package com.afternote.feature.setting.presentation.profile + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +import kotlinx.coroutines.CompletableDeferred +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import com.afternote.core.ui.R as CoreUiR + +/** 실제 Activity와 ActivityResultRegistry를 저장/복원한다. 피커 UI와 OS 프로세스 종료는 실행하지 않는다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditActivityRestorationTest { + @get:Rule + val composeRule = createEmptyComposeRule() + + private var controller: ActivityController? = null + + @After + fun tearDown() { + controller?.pause()?.stop()?.destroy() + controller = null + ProfilePickerRestorationActivity.profileRepository = null + } + + @Test + fun `새 Activity에 복원된 피커 결과는 조회 완료까지 보관하고 두번째 복원 후에도 선택을 유지한다`() { + val initialRepository = FakeMyProfileRepository(profile = SAVED) + ProfilePickerRestorationActivity.profileRepository = initialRepository + val first = launch() + waitForSelection(first, null) + val badge = + ApplicationProvider + .getApplicationContext() + .getString(CoreUiR.string.core_ui_content_description_profile_edit) + composeRule.onNodeWithContentDescription(badge).performClick() + val request = checkNotNull(shadowOf(first).nextStartedActivityForResult) + val firstState = destroyAndSave() + + // NonConfigurationInstances/VM을 넘기지 않고 새 Activity와 새 저장소를 만든다. + val load = CompletableDeferred() + val restoredRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { load.await() } } + ProfilePickerRestorationActivity.profileRepository = restoredRepository + val secondController = Robolectric.buildActivity(ProfilePickerRestorationActivity::class.java).create(firstState) + controller = secondController + val second = secondController.get() + assertNotSame(first, second) + assertNotSame(first.profileViewModel, second.profileViewModel) + assertEquals(ProfileEditUiState.Loading, second.profileViewModel.uiState.value) + + // 아직 compose launcher가 재등록되기 전 raw ActivityResult를 실제 registry에 넣는다. + assertTrue( + second.activityResultRegistry.dispatchResult(request.requestCode, Activity.RESULT_OK, Intent().setData(Uri.parse(PICKED))), + ) + secondController.start().resume().visible() + composeRule.waitForIdle() + assertEquals(ProfileEditUiState.Loading, second.profileViewModel.uiState.value) + + // 결과가 조회 대기 중 rememberSaveable에 들어간 뒤 다시 저장/복원한다. + val secondState = destroyAndSave() + val finalLoad = CompletableDeferred() + val finalRepository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { finalLoad.await() } } + ProfilePickerRestorationActivity.profileRepository = finalRepository + val third = launch(secondState) + composeRule.waitForIdle() + assertNotSame(second.profileViewModel, third.profileViewModel) + assertEquals(ProfileEditUiState.Loading, third.profileViewModel.uiState.value) + finalLoad.complete(SAVED) + waitForSelection(third, PICKED) + + composeRule.runOnIdle { + assertEquals(PICKED, (third.profileViewModel.uiState.value as ProfileEditUiState.Success).displayImageUri) + assertEquals(1, finalRepository.getProfileCalls) + assertTrue(finalRepository.profileUpdateCalls.isEmpty()) + assertNull((first.profileViewModel.uiState.value as ProfileEditUiState.Success).selectedImageUri) + } + } + + private fun launch(state: Bundle? = null): ProfilePickerRestorationActivity { + val next = + Robolectric + .buildActivity(ProfilePickerRestorationActivity::class.java) + .create(state) + .start() + .resume() + .visible() + controller = next + return next.get() + } + + private fun waitForSelection( + activity: ProfilePickerRestorationActivity, + uri: String?, + ) { + composeRule.waitUntil(5_000) { + val state = activity.profileViewModel.uiState.value + state is ProfileEditUiState.Success && state.selectedImageUri == uri + } + composeRule.waitForIdle() + } + + private fun destroyAndSave(): Bundle { + val active = checkNotNull(controller) + val state = Bundle() + active + .pause() + .saveInstanceState(state) + .stop() + .destroy() + controller = null + // Parcel round trip은 원 Activity/registry의 메모리 객체 공유를 피한다. + val parcel = Parcel.obtain() + return try { + parcel.writeBundle(state) + parcel.setDataPosition(0) + checkNotNull(parcel.readBundle(ProfilePickerRestorationActivity::class.java.classLoader)) + } finally { + parcel.recycle() + } + } + + private companion object { + const val PICKED = "content://media/picker/0/profile/1438" + val SAVED = User(name = "QA", email = "qa@example.test", phone = null, profileImageUrl = null) + } +} + +/** 외부 서버와 DI만 대체한다. Activity의 registry와 saved-state 처리는 그대로 실행한다. */ +class ProfilePickerRestorationActivity : ComponentActivity() { + internal lateinit var profileViewModel: ProfileEditViewModel + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repository = checkNotNull(profileRepository) + profileViewModel = + ViewModelProvider( + this, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) as T + }, + )[ProfileEditViewModel::class.java] + setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = profileViewModel) + } + } + } + + companion object { + internal var profileRepository: FakeMyProfileRepository? = null + } +} From fce9828b41354dfa27095a7bbbf3b3c77b28010e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:11:49 +0900 Subject: [PATCH 045/116] test(setting): cover API 29 biometric failure handling (#2195) --- feature/setting/presentation/build.gradle.kts | 1 + .../passkey/PassKeyMakingApi29Test.kt | 171 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 4eb2c8004c..43ea249131 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.robolectric) + testImplementation(libs.androidx.datastore.preferences) debugImplementation(libs.androidx.compose.ui.test.manifest) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt new file mode 100644 index 0000000000..fc34129104 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt @@ -0,0 +1,171 @@ +package com.afternote.feature.setting.presentation.passkey + +import android.app.KeyguardManager +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelStore +import com.afternote.core.common.biometric.BiometricAuthResult +import com.afternote.core.common.biometric.BiometricMessages +import com.afternote.core.common.biometric.authenticateBiometric +import com.afternote.core.datastore.UserProfileDataSource +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowBiometricManager +import org.robolectric.shadows.ShadowBuild + +/** Android 10에서 #2202가 바꾼 크래시와 오류 팝업 경로를 실제 AndroidX 구현으로 확인한다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class PassKeyMakingApi29Test { + @get:Rule + val composeRule = createComposeRule() + + private val viewModelStore = ViewModelStore() + private var activityController: ActivityController? = null + + @After + fun tearDown() { + viewModelStore.clear() + activityController?.pause()?.stop()?.destroy() + } + + @Test + fun `기존 패스키 프롬프트 허용자 조합은 API29에서 예외를 던진다`() { + val failure = + assertThrows(IllegalArgumentException::class.java) { + BiometricPrompt.PromptInfo + .Builder() + .setTitle("패스키 인증") + .setSubtitle("본인 인증") + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .build() + } + + assertTrue(failure.message.orEmpty().contains("unsupported on API 29")) + } + + @Test + fun `공용 래퍼는 API29의 미지원 조합을 크래시 대신 오류로 반환한다`() { + val activity = secureBiometricActivity() + val messages = + BiometricMessages( + initFailed = "init failed", + noHardware = "no hardware", + noneEnrolled = "none enrolled", + hwUnavailable = "hardware unavailable", + notAvailable = "unsupported authenticators", + verificationFailed = "verification failed", + ) + + composeRule.runOnIdle { + val result = runBlocking { activity.authenticateBiometric("패스키 인증", "본인 인증", messages) } + + assertEquals(BiometricAuthResult.Error(messages.notAvailable), result) + } + } + + @Test + fun `활성화된 지문 인증 버튼은 미지원 오류를 표시하고 패스키를 등록하지 않는다`() { + val activity = secureBiometricActivity() + val preferences = RecordingPreferences() + val viewModel = PassKeyViewModel(UserProfileDataSource(preferences)) + viewModelStore.put("passkey", viewModel) + var backCalls = 0 + var passwordCalls = 0 + val unavailableMessage = activity.getString(R.string.setting_biometric_not_available) + + composeRule.setContent { + CompositionLocalProvider(LocalContext provides activity) { + AfternoteTheme { + PassKeyMakingScreen( + onBackClick = { backCalls++ }, + onPasswordAuthClick = { passwordCalls++ }, + viewModel = viewModel, + ) + } + } + } + + composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() + + composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() + composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() + composeRule.runOnIdle { + assertEquals(0, preferences.updateCalls) + assertEquals(false, viewModel.isPasskeyRegistered.value) + assertEquals(0, backCalls) + assertEquals(0, passwordCalls) + } + composeRule.onNodeWithText("확인").performClick() + composeRule.onNodeWithText(unavailableMessage).assertDoesNotExist() + composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() + composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() + composeRule.runOnIdle { assertEquals(0, preferences.updateCalls) } + } + + private fun secureBiometricActivity(): FragmentActivity { + val activity = + Robolectric + .buildActivity(FragmentActivity::class.java) + .setup() + .also { activityController = it } + .get() + shadowOf(activity.getSystemService(KeyguardManager::class.java)).apply { + setIsKeyguardSecure(true) + setIsDeviceSecure(true) + } + // AndroidX Biometric 1.1.0의 강한 생체 인증 보장 기기 목록에 있는 모델을 사용한다. + ShadowBuild.setModel("Pixel 4") + Shadow + .extract( + activity.getSystemService(android.hardware.biometrics.BiometricManager::class.java), + ).setCanAuthenticate(true) + + // 실제 AVD에서 버튼이 비활성이라 미도달했던 경로를 열린 상태로 검증한다. + assertEquals(BiometricManager.BIOMETRIC_SUCCESS, BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG)) + assertEquals( + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, + BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL), + ) + return activity + } + + private class RecordingPreferences : DataStore { + override val data = MutableStateFlow(emptyPreferences()) + var updateCalls = 0 + private set + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + updateCalls++ + return transform(data.value).also { data.value = it } + } + } +} From a50d04d4fdcd25ea6c069f143ddf76b8c0c9cffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 17:42:07 +0900 Subject: [PATCH 046/116] test(setting): construct API 29 regression through Hilt (#2195) --- feature/setting/presentation/build.gradle.kts | 3 +- .../passkey/PassKeyMakingApi29Test.kt | 94 +++++++++++++------ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 43ea249131..e9e8411d0a 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -48,7 +48,8 @@ dependencies { testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.robolectric) - testImplementation(libs.androidx.datastore.preferences) + testImplementation(libs.hilt.android.testing) + kspTest(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.test.manifest) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt index fc34129104..f61c37fc07 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/passkey/PassKeyMakingApi29Test.kt @@ -12,23 +12,32 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelProvider import com.afternote.core.common.biometric.BiometricAuthResult import com.afternote.core.common.biometric.BiometricMessages import com.afternote.core.common.biometric.authenticateBiometric -import com.afternote.core.datastore.UserProfileDataSource +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.push.DevicePushTargetProvider +import com.afternote.core.domain.repository.UserProfileCacheRepository import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.SettingAccountRepository +import com.afternote.feature.setting.domain.SettingNotificationRepository +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.R -import kotlinx.coroutines.flow.MutableStateFlow +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -40,20 +49,57 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowBiometricManager import org.robolectric.shadows.ShadowBuild +import javax.inject.Inject /** Android 10에서 #2202가 바꾼 크래시와 오류 팝업 경로를 실제 AndroidX 구현으로 확인한다. */ @RunWith(RobolectricTestRunner::class) -@Config(sdk = [29]) +@HiltAndroidTest +@Config(sdk = [29], application = HiltTestApplication::class) class PassKeyMakingApi29Test { - @get:Rule + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val composeRule = createComposeRule() - private val viewModelStore = ViewModelStore() - private var activityController: ActivityController? = null + @BindValue + val accountRepository: SettingAccountRepository = FakeSettingAccountRepository.strict() + + @BindValue + val notificationRepository: SettingNotificationRepository = FakeSettingNotificationRepository.strict() + + @BindValue + val pushTargetProvider: DevicePushTargetProvider = + object : DevicePushTargetProvider { + override suspend fun currentTargetId(): String? = error("생체 인증 오류 경로는 푸시 대상을 조회하지 않는다") + + override suspend fun existingTargetId(): String? = error("생체 인증 오류 경로는 푸시 대상을 조회하지 않는다") + } + + @BindValue + val errorReporter: ErrorReporter = + object : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + error("생체 인증 오류 경로에서 예상하지 않은 오류 보고") + } + } + + @Inject + lateinit var profileCache: UserProfileCacheRepository + + private var activityController: ActivityController? = null + + @Before + fun setUp() { + hiltRule.inject() + runBlocking { profileCache.savePasskeyRegistered(false) } + } @After fun tearDown() { - viewModelStore.clear() activityController?.pause()?.stop()?.destroy() } @@ -95,9 +141,8 @@ class PassKeyMakingApi29Test { @Test fun `활성화된 지문 인증 버튼은 미지원 오류를 표시하고 패스키를 등록하지 않는다`() { val activity = secureBiometricActivity() - val preferences = RecordingPreferences() - val viewModel = PassKeyViewModel(UserProfileDataSource(preferences)) - viewModelStore.put("passkey", viewModel) + val viewModel = ViewModelProvider(activity)[PassKeyViewModel::class.java] + composeRule.waitUntil { viewModel.isPasskeyRegistered.value == false } var backCalls = 0 var passwordCalls = 0 val unavailableMessage = activity.getString(R.string.setting_biometric_not_available) @@ -119,22 +164,23 @@ class PassKeyMakingApi29Test { composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() composeRule.runOnIdle { - assertEquals(0, preferences.updateCalls) assertEquals(false, viewModel.isPasskeyRegistered.value) assertEquals(0, backCalls) assertEquals(0, passwordCalls) } + assertEquals(false, runBlocking { profileCache.isPasskeyRegisteredFlow().first() }) composeRule.onNodeWithText("확인").performClick() composeRule.onNodeWithText(unavailableMessage).assertDoesNotExist() composeRule.onNodeWithText("지문 인증하기").assertIsEnabled().performClick() composeRule.onNodeWithText(unavailableMessage).assertIsDisplayed() - composeRule.runOnIdle { assertEquals(0, preferences.updateCalls) } + composeRule.runOnIdle { assertEquals(false, viewModel.isPasskeyRegistered.value) } + assertEquals(false, runBlocking { profileCache.isPasskeyRegisteredFlow().first() }) } private fun secureBiometricActivity(): FragmentActivity { val activity = Robolectric - .buildActivity(FragmentActivity::class.java) + .buildActivity(BiometricTestActivity::class.java) .setup() .also { activityController = it } .get() @@ -157,15 +203,7 @@ class PassKeyMakingApi29Test { ) return activity } - - private class RecordingPreferences : DataStore { - override val data = MutableStateFlow(emptyPreferences()) - var updateCalls = 0 - private set - - override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { - updateCalls++ - return transform(data.value).also { data.value = it } - } - } } + +@AndroidEntryPoint +class BiometricTestActivity : FragmentActivity() From 2398257f8d0d677c51c510cf3058644851502cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:23:50 +0900 Subject: [PATCH 047/116] fix(core): preserve session on password mismatch (#2222) --- .../network/interceptor/TokenAuthenticator.kt | 28 +++++ ....kt => TokenAuthenticationPipelineTest.kt} | 99 ++++++++++++++--- .../interceptor/TokenAuthenticatorTest.kt | 104 ++++++++++++++++++ 3 files changed, 216 insertions(+), 15 deletions(-) rename core/network/src/test/kotlin/com/afternote/core/network/di/{ConcurrentUnauthorizedReissueTest.kt => TokenAuthenticationPipelineTest.kt} (78%) diff --git a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt index 974bac1bc4..52b1d42584 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt @@ -4,11 +4,17 @@ import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.network.token.TokenReissuer import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import java.io.IOException +import java.nio.charset.CharacterCodingException import javax.inject.Inject class TokenAuthenticator @@ -23,6 +29,10 @@ class TokenAuthenticator route: Route?, response: Response, ): Request? { + // 현재 비밀번호 불일치도 HTTP 401이다. 토큰 갱신이나 한도 초과로 세션을 지우지 않고 + // 원래 오류 본문을 Retrofit의 실패 처리에 넘긴다. + if (response.isPasswordMismatch()) return null + if (response.responseCount >= 3) { errorReporter.recordAuthContractViolation(AUTH_STAGE_RETRY_LIMIT) runBlocking { authRepository.get().clearSession() } @@ -70,6 +80,22 @@ class TokenAuthenticator } } +/** 확인된 업무 오류만 제외한다. 알 수 없거나 파싱할 수 없는 401은 기존 갱신 경로를 따른다. */ +private fun Response.isPasswordMismatch(): Boolean { + val body = peekBody(MAX_ERROR_BODY_BYTES + 1) + if (body.contentLength() > MAX_ERROR_BODY_BYTES) return false + val parsed = + try { + Json.parseToJsonElement(body.bytes().decodeToString(throwOnInvalidSequence = true)) as? JsonObject + } catch (_: SerializationException) { + null + } catch (_: CharacterCodingException) { + null + } + val code = parsed?.get("code") as? JsonPrimitive + return code != null && !code.isString && code.intOrNull == CODE_PASSWORD_MISMATCH +} + private fun ErrorReporter.recordAuthContractViolation(authStage: String) { recordFailure( throwable = IllegalStateException("Token authenticator contract violation"), @@ -101,3 +127,5 @@ private const val KEY_AUTH_STAGE = "auth_stage" private const val AUTH_STAGE_RETRY_LIMIT = "retry_limit" private const val AUTH_STAGE_MISSING_AUTH_HEADER = "missing_auth_header" private const val AUTH_STAGE_SAME_TOKEN = "same_token" +private const val CODE_PASSWORD_MISMATCH = 1202 +private const val MAX_ERROR_BODY_BYTES = 64 * 1024L diff --git a/core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/di/TokenAuthenticationPipelineTest.kt similarity index 78% rename from core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt rename to core/network/src/test/kotlin/com/afternote/core/network/di/TokenAuthenticationPipelineTest.kt index b54bd99f68..3c993615d8 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/di/ConcurrentUnauthorizedReissueTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/di/TokenAuthenticationPipelineTest.kt @@ -7,6 +7,8 @@ import com.afternote.core.network.calladapter.ApiErrorCallAdapterFactory import com.afternote.core.network.dto.ReissueRequestDto import com.afternote.core.network.interceptor.AuthInterceptor import com.afternote.core.network.interceptor.TokenAuthenticator +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.TokenApiService import com.afternote.core.network.token.AccessTokenExpiryTracker import com.afternote.core.network.token.TokenReissuer @@ -20,9 +22,12 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import retrofit2.create +import retrofit2.http.POST import java.io.BufferedInputStream import java.io.ByteArrayOutputStream import java.io.IOException @@ -34,6 +39,7 @@ import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +import retrofit2.Call as RetrofitCall /** * 액세스 토큰이 만료된 채 콜드 스타트하면 같은 호스트로 동시에 나간 인증 요청이 전부 401 을 받는다. @@ -43,11 +49,11 @@ import java.util.concurrent.atomic.AtomicInteger * Retrofit 을 그대로 쓰고, 기본 주소만 로컬 서버로 바꾼다. 요청 5건도 콜드 스타트 스레드 덤프에 * 찍힌 경로 그대로다. 주간 리포트는 운영처럼 느린 경로용 파생 클라이언트를 탄다. * - * 서버는 만료 토큰 요청이 [ColdStartServer.expiredBarrier] 수만큼 모일 때까지 401 을 쥐고 있는다. + * 서버는 만료 토큰 요청이 [AuthenticationServer.expiredBarrier] 수만큼 모일 때까지 401 을 쥐고 있는다. * 첫 401 이 재발급을 부르기 전에 요청 전부가 디스패처 실행 칸을 차지하게 해, 운 좋게 먼저 끝난 * 요청이 칸을 비워 주는 경합을 없애기 위해서다. */ -class ConcurrentUnauthorizedReissueTest { +class TokenAuthenticationPipelineTest { private val closeables = mutableListOf() @After @@ -65,9 +71,53 @@ class ConcurrentUnauthorizedReissueTest { assertColdStartCompletes(paths = COLD_START_PATHS.take(4)) } + @Test + fun `비밀번호 불일치는 실제 요청 한 번으로 끝나고 Retrofit에 원래 오류를 전달한다`() { + assertPasswordChange(initialAccessToken = FRESH_ACCESS_TOKEN, responseStatus = 401, expectedReissues = 0) + } + + @Test + fun `토큰 거절 1000 뒤 비밀번호 불일치는 실제 재발급 한 번으로 끝난다`() { + assertPasswordChange(initialAccessToken = EXPIRED_ACCESS_TOKEN, responseStatus = 401, expectedReissues = 1) + } + + @Test + fun `정상 비밀번호 변경은 재발급 없이 성공한다`() { + assertPasswordChange(initialAccessToken = FRESH_ACCESS_TOKEN, responseStatus = 200, expectedReissues = 0) + } + + private fun assertPasswordChange( + initialAccessToken: String, + responseStatus: Int, + expectedReissues: Int, + ) { + val server = AuthenticationServer(expiredBarrier = 1, passwordResponseStatus = responseStatus).also(closeables::add) + val wiring = AuthenticationWiring(server.baseUrl, initialAccessToken).also(closeables::add) + + if (responseStatus == 401) { + val failure = + assertThrows(ApiException::class.java) { + wiring.passwordApi.changePassword().execute() + } + assertEquals(401, failure.status) + assertEquals(1202, failure.code) + assertEquals("현재 비밀번호가 일치하지 않습니다.", failure.serverMessage) + } else { + val response = wiring.passwordApi.changePassword().execute() + assertEquals(200, response.body()?.status) + } + + assertEquals(expectedReissues, server.reissueHits.get()) + assertEquals(expectedReissues + 1, server.passwordHits.get()) + assertEquals(0, wiring.repository.clearSessionCallCount) + assertTrue(wiring.repository.loggedIn) + assertEquals(FRESH_ACCESS_TOKEN, wiring.repository.accessToken) + assertEquals(0, wiring.reporter.writtenFailures.size) + } + private fun assertColdStartCompletes(paths: List) { - val server = ColdStartServer(expiredBarrier = paths.size).also(closeables::add) - val wiring = ColdStartWiring(server.baseUrl).also(closeables::add) + val server = AuthenticationServer(expiredBarrier = paths.size).also(closeables::add) + val wiring = AuthenticationWiring(server.baseUrl).also(closeables::add) val finished = CountDownLatch(paths.size) val statuses = ConcurrentLinkedQueue() @@ -118,14 +168,15 @@ class ConcurrentUnauthorizedReissueTest { } /** 운영 [NetworkModule]·[ServiceModule] 조립을 그대로 따르되 기본 주소만 로컬 서버로 바꾼 묶음. */ - private class ColdStartWiring( + private class AuthenticationWiring( baseUrl: String, + initialAccessToken: String = EXPIRED_ACCESS_TOKEN, ) : AutoCloseable { private val json = NetworkModule.provideJson() private val callAdapterFactory = ApiErrorCallAdapterFactory(json) private val loggingInterceptor = HttpLoggingInterceptor() private val tracker = AccessTokenExpiryTracker { 0L } - private val reporter = FakeErrorReporter() + val reporter = FakeErrorReporter() val baseClient: OkHttpClient = NetworkModule.provideBaseOkHttpClient() private val refreshClient: OkHttpClient = NetworkModule.provideRefreshOkHttpClient(baseClient, loggingInterceptor) @@ -142,10 +193,10 @@ class ConcurrentUnauthorizedReissueTest { .create() /** `AuthRepositoryImpl.rotateToken` 과 같은 순서: 저장 refresh 로 재발급을 치고 새 토큰을 저장한다. */ - private val repository = + val repository = FakeAuthRepository( loggedIn = true, - accessToken = EXPIRED_ACCESS_TOKEN, + accessToken = initialAccessToken, refreshToken = STORED_REFRESH_TOKEN, onRotateToken = { runCatching { @@ -170,13 +221,15 @@ class ConcurrentUnauthorizedReissueTest { ) /** 운영 Retrofit 의 호출 팩토리 — 주간 리포트만 느린 경로용 파생 클라이언트로 가른다. */ - val callFactory: Call.Factory = + private val retrofit = NetworkModule .provideRetrofit(mainClient, json, callAdapterFactory) .newBuilder() .baseUrl(baseUrl) .build() - .callFactory() + + val callFactory: Call.Factory = retrofit.callFactory() + val passwordApi: PasswordApi = retrofit.create() override fun close() { // 재발급 클라이언트는 디스패처를 따로 쓴다(#2160) — 둘 다 닫아야 스레드가 남지 않는다. @@ -188,6 +241,11 @@ class ConcurrentUnauthorizedReissueTest { } } + private interface PasswordApi { + @POST("auth/password/change") + fun changePassword(): RetrofitCall> + } + private data class ColdStartRequest( val method: String, val path: String, @@ -205,13 +263,15 @@ class ConcurrentUnauthorizedReissueTest { * 만료 토큰에는 401, 재발급에는 새 토큰, 새 토큰에는 200 으로 답하는 최소 HTTP/1.1 서버. * 새 테스트 의존성 없이 소켓 경계를 실제로 지나게 하려고 `java.net` 만 쓴다. */ - private class ColdStartServer( + private class AuthenticationServer( expiredBarrier: Int, + private val passwordResponseStatus: Int = 200, ) : AutoCloseable { private val serverSocket = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) private val sockets = ConcurrentLinkedQueue() private val expiredArrivals = CountDownLatch(expiredBarrier) val reissueHits = AtomicInteger() + val passwordHits = AtomicInteger() val baseUrl = "http://${serverSocket.inetAddress.hostAddress}:${serverSocket.localPort}/api/v1/" init { @@ -262,15 +322,21 @@ class ConcurrentUnauthorizedReissueTest { private fun respond( requestLine: String, authorization: String?, - ): Pair = - when { + ): Pair { + val isPasswordChange = requestLine.startsWith("POST /api/v1/auth/password/change ") + if (isPasswordChange) passwordHits.incrementAndGet() + return when { requestLine.startsWith("POST /api/v1/auth/reissue ") -> { reissueHits.incrementAndGet() 200 to REISSUE_RESPONSE } authorization == "Bearer $FRESH_ACCESS_TOKEN" -> { - 200 to OK_RESPONSE + if (isPasswordChange && passwordResponseStatus == 401) { + 401 to PASSWORD_MISMATCH_RESPONSE + } else { + 200 to OK_RESPONSE + } } else -> { @@ -279,6 +345,7 @@ class ConcurrentUnauthorizedReissueTest { 401 to EXPIRED_RESPONSE } } + } override fun close() { serverSocket.close() @@ -308,7 +375,9 @@ class ConcurrentUnauthorizedReissueTest { ) const val OK_RESPONSE = """{"status":200,"code":200,"message":"ok","data":null}""" - const val EXPIRED_RESPONSE = """{"status":401,"code":1106,"message":"expired"}""" + const val EXPIRED_RESPONSE = """{"status":401,"code":1000,"message":"expired"}""" + const val PASSWORD_MISMATCH_RESPONSE = + """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""" const val REISSUE_RESPONSE = """{"status":200,"code":200,"message":"ok",""" + """"data":{"accessToken":"$FRESH_ACCESS_TOKEN","refreshToken":"fresh-refresh","expiresIn":3600}}""" diff --git a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt index ead8b38dc5..95a762598d 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt @@ -11,6 +11,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame @@ -48,6 +49,7 @@ class TokenAuthenticatorTest { private fun unauthorizedResponse( accessToken: String? = "old-token", priorCount: Int = 0, + body: String = "", ): Response { val requestBuilder = Request.Builder().url("https://afternote.kro.kr/api/v1/test") if (accessToken != null) requestBuilder.header("Authorization", "Bearer $accessToken") @@ -72,9 +74,111 @@ class TokenAuthenticatorTest { .code(401) .message("Unauthorized") .priorResponse(prior) + .body(body.toResponseBody()) .build() } + @Test + fun `비밀번호 불일치 1202는 갱신과 세션 정리 없이 원래 본문을 남긴다`() { + listOf(0, 1, 2).forEach { priorCount -> + val reporter = FakeErrorReporter() + val repository = networkFakeAuthRepository(accessToken = "old-token") + val body = """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""" + val response = unauthorizedResponse(priorCount = priorCount, body = body) + + assertNull(authenticator(repository, reporter).authenticate(null, response)) + + assertEquals(0, repository.rotateCallCount) + assertEquals(0, repository.clearSessionCallCount) + assertEquals("old-token", repository.accessToken) + assertEquals(0, reporter.writtenFailures.size) + assertEquals(body, response.body.string()) + } + } + + @Test + fun `토큰 거절 1000과 미확인 또는 파싱 불가 401은 기존 갱신 경로를 따른다`() { + listOf( + """{"status":401,"code":1000}""", + """{"status":401,"code":2701}""", + """{"status":401,"code":9999}""", + """{"status":401}""", + """{"code":"1202"}""", + """{"code":1202.0}""", + """{"code":4294968498}""", + """{"code":{"value":1202}}""", + """{"code":1202""", + "not json", + "[]", + "", + """{"code":1202}""" + " ".repeat(64 * 1024) + "invalid suffix", + ).forEach { body -> + val repository = + networkFakeAuthRepository( + accessToken = "old-token", + onRotateToken = { + accessToken = "fresh-token" + Result.success(TokenBundle(accessToken = "fresh-token", refreshToken = "r")) + }, + ) + val response = unauthorizedResponse(body = body) + + val request = authenticator(repository).authenticate(null, response) + + assertEquals(body, 1, repository.rotateCallCount) + assertEquals(body, "Bearer fresh-token", request?.header("Authorization")) + assertEquals(body, 0, repository.clearSessionCallCount) + assertEquals(body, response.body.string()) + } + } + + @Test + fun `잘못된 UTF-8 응답은 비밀번호 불일치로 단정하지 않고 원래 바이트를 보존한다`() { + val repository = + networkFakeAuthRepository( + accessToken = "old-token", + onRotateToken = { + accessToken = "fresh-token" + Result.success(TokenBundle(accessToken = "fresh-token", refreshToken = "r")) + }, + ) + val body = """{"code":1202,"message":"""".toByteArray() + byteArrayOf(0xC3.toByte()) + "\"}".toByteArray() + val response = unauthorizedResponse().newBuilder().body(body.toResponseBody()).build() + + val request = authenticator(repository).authenticate(null, response) + + assertEquals(1, repository.rotateCallCount) + assertEquals("Bearer fresh-token", request?.header("Authorization")) + assertEquals(0, repository.clearSessionCallCount) + assertArrayEquals(body, response.body.bytes()) + } + + @Test + fun `토큰 거절 뒤 갱신한 요청이 1202면 추가 갱신 없이 세션을 유지한다`() { + val reporter = FakeErrorReporter() + val repository = + networkFakeAuthRepository( + accessToken = "old-token", + onRotateToken = { + accessToken = "fresh-token" + Result.success(TokenBundle(accessToken = "fresh-token", refreshToken = "r")) + }, + ) + val authenticator = authenticator(repository, reporter) + val expiredResponse = unauthorizedResponse(body = """{"status":401,"code":1000}""") + val retry = authenticator.authenticate(null, expiredResponse) + val mismatchResponse = + unauthorizedResponse(accessToken = "fresh-token", priorCount = 1, body = """{"status":401,"code":1202}""") + + assertEquals("Bearer fresh-token", retry?.header("Authorization")) + assertNull(authenticator.authenticate(null, mismatchResponse)) + + assertEquals(1, repository.rotateCallCount) + assertEquals(0, repository.clearSessionCallCount) + assertEquals("fresh-token", repository.accessToken) + assertEquals(0, reporter.writtenFailures.size) + } + @Test fun `재시도 3회 도달 - 회전 없이 세션 정리 후 중단`() { val reporter = FakeErrorReporter() From f9744a048f2f65adae880e9145d101750ae9fa80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:28:40 +0900 Subject: [PATCH 048/116] fix(setting): validate profile contact before saving (#2223) --- .../presentation/profile/ProfileEditScreen.kt | 18 ++- .../profile/ProfileEditViewModel.kt | 3 + .../profile/ProfileEditPhoneTest.kt | 147 ++++++++++++++++++ .../profile/ProfileEditViewModelTest.kt | 62 ++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index d840cff695..7100002cee 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -30,6 +30,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,6 +42,9 @@ import com.afternote.core.ui.button.AfternoteButtonType import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation +import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone @Composable internal fun ProfileEditScreen( @@ -147,6 +152,7 @@ private fun ProfileEditForm( val nameState = rememberTextFieldState(initialText = state.name) val phoneState = rememberTextFieldState(initialText = state.phone) val emailState = rememberTextFieldState(initialText = state.email) + val isPhoneInvalid = phoneState.text.toString().validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID LazyColumn( modifier = modifier.fillMaxSize(), @@ -199,8 +205,18 @@ private fun ProfileEditForm( AfternoteTextField( state = phoneState, placeholder = "연락처를 지정해주세요", + keyboardType = KeyboardType.Phone, + isError = isPhoneInvalid, modifier = Modifier.fillMaxWidth(), ) + if (isPhoneInvalid) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.setting_receiver_phone_invalid), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } } } item { @@ -225,7 +241,7 @@ private fun ProfileEditForm( AfternoteButton( text = "수정하기", onClick = { onUpdateClick(nameState.text.toString(), phoneState.text.toString()) }, - type = if (state.isUpdating) AfternoteButtonType.Un else AfternoteButtonType.Default, + type = if (state.isUpdating || isPhoneInvalid) AfternoteButtonType.Un else AfternoteButtonType.Default, modifier = Modifier .padding(horizontal = 20.dp) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index cd197676bb..bc71680f77 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -5,6 +5,8 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation +import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -94,6 +96,7 @@ internal class ProfileEditViewModel ) { val current = currentState as? ProfileEditUiState.Success ?: return if (current.isUpdating) return + if (phone.validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID) return dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt new file mode 100644 index 0000000000..89dcd7b157 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditPhoneTest.kt @@ -0,0 +1,147 @@ +package com.afternote.feature.setting.presentation.profile + +import android.text.InputType +import android.view.inputmethod.EditorInfo +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextReplacement +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +import kotlinx.coroutines.awaitCancellation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalComposeUiApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ProfileEditPhoneTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `연락처를 누르면 전화번호 키보드를 요청한다`() { + val viewModel = viewModel(FakeMyProfileRepository(profile = PROFILE)) + var inputType: Int? = null + composeRule.setContent { + AfternoteTheme { + InterceptPlatformTextInput( + interceptor = { request, _ -> + val editorInfo = EditorInfo() + request.createInputConnection(editorInfo) + inputType = editorInfo.inputType + awaitCancellation() + }, + ) { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + } + awaitSuccess(viewModel) + + phoneField(PHONE).performScrollTo().performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { inputType != null } + composeRule.runOnIdle { assertEquals(InputType.TYPE_CLASS_PHONE, inputType!! and InputType.TYPE_MASK_CLASS) } + } + + @Test + fun `서버의 잘못된 연락처에도 오류가 보이고 저장이 막힌다`() { + val repository = FakeMyProfileRepository(profile = PROFILE.copy(phone = INVALID_PHONE)) + val viewModel = viewModel(repository) + setContent(viewModel) + awaitSuccess(viewModel) + + scrollToText(PHONE_ERROR) + composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed() + scrollToText("수정하기") + composeRule + .onNodeWithText("수정하기") + .assertIsNotEnabled() + .performClick() + + composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) } + } + + @Test + fun `붙여넣은 잘못된 연락처를 고치면 오류가 해제되고 저장된다`() { + val repository = FakeMyProfileRepository(profile = PROFILE) + val viewModel = viewModel(repository) + var navigatedBack = 0 + setContent(viewModel) { navigatedBack++ } + awaitSuccess(viewModel) + + phoneField(PHONE).performScrollTo().performTextReplacement(INVALID_PHONE) + scrollToText(PHONE_ERROR) + composeRule.onNodeWithText(PHONE_ERROR).assertIsDisplayed() + scrollToText("수정하기") + composeRule.onNodeWithText("수정하기").assertIsNotEnabled() + composeRule.runOnIdle { assertTrue(repository.profileUpdateCalls.isEmpty()) } + + scrollToText(INVALID_PHONE) + phoneField(INVALID_PHONE).performTextReplacement("010-5555-6666") + composeRule.onNodeWithText(PHONE_ERROR).assertDoesNotExist() + scrollToText("수정하기") + composeRule + .onNodeWithText("수정하기") + .assertIsEnabled() + .performClick() + + composeRule.waitUntil(TIMEOUT_MILLIS) { navigatedBack == 1 } + composeRule.runOnIdle { + assertEquals( + listOf(FakeMyProfileRepository.ProfileUpdateCall("기존 이름", "010-5555-6666", null)), + repository.profileUpdateCalls, + ) + } + } + + private fun scrollToText(value: String) { + composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(value)) + } + + private fun phoneField(value: String) = composeRule.onNode(hasSetTextAction() and hasText(value)) + + private fun setContent( + viewModel: ProfileEditViewModel, + onBackClick: () -> Unit = {}, + ) { + composeRule.setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = onBackClick, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + } + + private fun viewModel(repository: FakeMyProfileRepository) = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + + private fun awaitSuccess(viewModel: ProfileEditViewModel) { + composeRule.waitUntil(TIMEOUT_MILLIS) { viewModel.uiState.value is ProfileEditUiState.Success } + composeRule.waitForIdle() + } + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + const val PHONE = "01012345678" + const val INVALID_PHONE = "01055556666ggyyy" + const val PHONE_ERROR = "올바른 연락처를 입력해주세요." + val PROFILE = User("기존 이름", "qa@afternote.local", PHONE, null) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt index 7064426769..7e4b4f6685 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModelTest.kt @@ -42,6 +42,68 @@ class ProfileEditViewModelTest { Dispatchers.resetMain() } + @Test + fun `잘못된 연락처는 사진 업로드와 프로필 수정 전에 차단한다`() = + runTest(dispatcher) { + for (phone in listOf("01055556666ggyyy", "0101234567", "02012345678", "010--1234-5678")) { + val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = phone)) + val uploadRepository = FakePhotoUploadRepository.strict() + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone)) + runCurrent() + + assertTrue(profileRepository.profileUpdateCalls.isEmpty()) + assertTrue(uploadRepository.uploads.isEmpty()) + assertFalse(viewModel.success().isUpdating) + assertNull(viewModel.success().pendingEvent) + assertEquals(PICKED_PHOTO, viewModel.success().selectedImageUri) + } + } + + @Test + fun `빈 연락처와 유효한 휴대폰의 기존 저장 값을 보존한다`() = + runTest(dispatcher) { + for (phone in listOf("", " ", "01012345678", "010-1234-5678", "011-123-4567")) { + val profileRepository = FakeMyProfileRepository(profile = SAVED) + val viewModel = ProfileEditViewModel(profileRepository, FakePhotoUploadRepository.strict()) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = phone)) + runCurrent() + + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = phone.takeIf { it.isNotBlank() }, profileImageUrl = null)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + } + + @Test + fun `잘못된 연락처를 고치면 보관한 사진과 함께 저장할 수 있다`() = + runTest(dispatcher) { + val profileRepository = FakeMyProfileRepository(profile = SAVED.copy(phone = "01055556666ggyyy")) + val uploadRepository = FakePhotoUploadRepository(uploadedKey = UPLOADED_KEY) + val viewModel = ProfileEditViewModel(profileRepository, uploadRepository) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto(PICKED_PHOTO)) + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666ggyyy")) + runCurrent() + + viewModel.onIntent(ProfileEditIntent.UpdateProfile(name = "새 이름", phone = "01055556666")) + runCurrent() + + assertEquals(listOf(PICKED_PHOTO to "profiles"), uploadRepository.uploads) + assertEquals( + listOf(ProfileUpdateCall(name = "새 이름", phone = "01055556666", profileImageUrl = UPLOADED_KEY)), + profileRepository.profileUpdateCalls, + ) + assertEquals(ProfileEditEvent.UpdateSuccess, viewModel.success().pendingEvent) + } + @Test fun `서버 프로필 사진을 싣고 고른 사진은 저장 전까지 그 위에만 보인다`() = runTest(dispatcher) { From 6436685709c0028876b910dfc3747e730acf9956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:45:15 +0900 Subject: [PATCH 049/116] test(setting): scroll to lazy save error before asserting (#2179) --- .../afternote_fe/SettingEditorSaveSignalAndroidTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index 410d021b9f..78f61b9665 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasScrollToIndexAction import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled @@ -18,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule @@ -118,8 +120,11 @@ class SettingEditorSaveSignalAndroidTest { composeRule.onNodeWithText(SAVE_ACTION).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - composeRule.onAllNodes(hasText(EDIT_FAILED_MESSAGE)).fetchSemanticsNodes().isNotEmpty() + composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size > updatesBefore } } + // 오류는 LazyColumn의 마지막 항목이므로 작은 화면에서도 스크롤해 실제 표시를 확인한다. + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(EDIT_FAILED_MESSAGE)) + composeRule.onNodeWithText(EDIT_FAILED_MESSAGE).assertIsDisplayed() // 실패는 화면을 닫지 않고, 저장 중 잠금도 풀려 다시 누를 수 있다. composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled() From 5ea27e7b37ff5fd10a08693f6126d659c62ff492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 20:46:26 +0900 Subject: [PATCH 050/116] =?UTF-8?q?fix:=20=EC=84=A4=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=98=A4=EB=A5=98=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EC=99=80=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=97=B0=EA=B2=B0=20(#708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/ConnectedAccountsContent.kt | 64 +++-- .../account/ConnectedAccountsContract.kt | 14 ++ .../account/ConnectedAccountsScreen.kt | 7 + .../account/ConnectedAccountsUiState.kt | 2 + .../account/ConnectedAccountsViewModel.kt | 58 ++++- .../presentation/home/SettingContract.kt | 68 ++++++ .../presentation/home/SettingScreen.kt | 219 +++++++++--------- .../presentation/home/SettingViewModel.kt | 156 +++++++------ .../home/WithdrawConfirmScreen.kt | 14 +- .../presentation/home/WithdrawGuideScreen.kt | 6 +- .../notification/PushNotificationContent.kt | 34 ++- .../notification/PushNotificationContract.kt | 2 + .../notification/PushNotificationScreen.kt | 1 + .../notification/PushNotificationUiState.kt | 2 + .../notification/PushNotificationViewModel.kt | 48 ++-- .../profile/ProfileEditContract.kt | 4 + .../presentation/profile/ProfileEditScreen.kt | 34 ++- .../profile/ProfileEditViewModel.kt | 41 ++-- .../component/SettingLoadErrorContent.kt | 42 ++++ .../src/main/res/values/strings.xml | 4 + .../ConnectedAccountsScreenScreenshotTest.kt | 21 +- .../PushNotificationScreenScreenshotTest.kt | 23 +- .../SettingAccountSecurityTest.kt | 10 +- .../presentation/SettingCompletionTest.kt | 14 +- .../SettingCoroutineCancellationTest.kt | 5 +- .../setting/presentation/SettingFlowTest.kt | 27 ++- .../presentation/SettingLoadFeedbackTest.kt | 111 +++++++++ .../presentation/SettingLoadRecoveryTest.kt | 184 +++++++++++++++ .../account/ConnectedAccountsMviTest.kt | 2 +- .../presentation/home/SettingViewModelTest.kt | 18 +- .../konsist/MviContractKonsistTest.kt | 1 - 31 files changed, 926 insertions(+), 310 deletions(-) create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt create mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt index 1a51323a4d..5ae70a7d4b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContent.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.account +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,10 +15,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.account.component.SocialAccountRow +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent /** * 연결된 계정 화면의 상태 없는 본문. @@ -30,6 +33,7 @@ internal fun ConnectedAccountsContent( snackbarHostState: SnackbarHostState, onBack: () -> Unit, onToggle: (provider: String, enabled: Boolean) -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -43,30 +47,46 @@ internal fun ConnectedAccountsContent( ) }, ) { paddingValues -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 20.dp, vertical = 16.dp), - ) { - Text( - text = stringResource(R.string.setting_sns_login_section_title), - style = AfternoteDesign.typography.bodyLargeR, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.setting_sns_login_section_desc), - style = AfternoteDesign.typography.bodySmallR, - ) - Spacer(modifier = Modifier.height(24.dp)) + when { + uiState.isLoading -> { + LoadingBody(modifier = Modifier.padding(paddingValues)) + } - uiState.accounts.forEach { account -> - SocialAccountRow( - account = account, - onToggle = { enabled -> onToggle(account.provider, enabled) }, + uiState.errorMessage != null -> { + SettingLoadErrorContent( + message = uiState.errorMessage, + onRetry = onRetry, + modifier = Modifier.padding(paddingValues), ) - Spacer(modifier = Modifier.height(8.dp)) + } + + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(R.string.setting_sns_login_section_title), + style = AfternoteDesign.typography.bodyLargeR, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.setting_sns_login_section_desc), + style = AfternoteDesign.typography.bodySmallR, + ) + Spacer(modifier = Modifier.height(24.dp)) + + uiState.accounts.forEach { account -> + SocialAccountRow( + account = account, + onToggle = { enabled -> if (!uiState.isUpdating) onToggle(account.provider, enabled) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt index 54ccab2404..cb5c3ae9af 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -4,6 +4,12 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ConnectedAccountsIntent : MviIntent { + data object RetryLoad : ConnectedAccountsIntent + + data class ConsumeError( + val message: String, + ) : ConnectedAccountsIntent + data class Toggle( val provider: String, val enabled: Boolean, @@ -22,6 +28,14 @@ internal sealed interface ConnectedAccountsIntent : MviIntent { } internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent { + data object Loading : ConnectedAccountsReducerEvent + + data object Changing : ConnectedAccountsReducerEvent + + data class ErrorConsumed( + val message: String, + ) : ConnectedAccountsReducerEvent + data class Loaded( val accounts: List, ) : ConnectedAccountsReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 784e1d093a..9decf5f17f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -89,10 +89,17 @@ internal fun ConnectedAccountsScreen( } } + uiState.pendingError?.let { message -> + ObserveSignal(message, ConnectedAccountsIntent.ConsumeError(message), viewModel::onIntent) { + linkScope.launch { snackbarHostState.showSnackbar(it) } + } + } + ConnectedAccountsContent( uiState = uiState, snackbarHostState = snackbarHostState, onBack = onBack, + onRetry = { viewModel.onIntent(ConnectedAccountsIntent.RetryLoad) }, onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) }, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt index e17aaa182f..56754d98a2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsUiState.kt @@ -6,6 +6,8 @@ data class ConnectedAccountsUiState( val isLoading: Boolean = false, val accounts: List = emptyList(), val errorMessage: String? = null, + val isUpdating: Boolean = false, + val pendingError: String? = null, /** * 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502). * diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 92a53c2e3c..045b26bde4 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -7,6 +7,7 @@ import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,12 +19,22 @@ internal class ConnectedAccountsViewModel ) : MviViewModel( ConnectedAccountsUiState(isLoading = true), ) { + private var loadJob: Job? = null + init { loadConnectedAccounts() } override fun onIntent(intent: ConnectedAccountsIntent) { when (intent) { + ConnectedAccountsIntent.RetryLoad -> { + if (currentState.errorMessage != null) loadConnectedAccounts() + } + + is ConnectedAccountsIntent.ConsumeError -> { + dispatch(ConnectedAccountsReducerEvent.ErrorConsumed(intent.message)) + } + is ConnectedAccountsIntent.Toggle -> { toggle(intent.provider, intent.enabled) } @@ -45,8 +56,26 @@ internal class ConnectedAccountsViewModel event: ConnectedAccountsReducerEvent, ): ConnectedAccountsUiState = when (event) { + ConnectedAccountsReducerEvent.Loading -> { + state.copy(isLoading = true, errorMessage = null) + } + + ConnectedAccountsReducerEvent.Changing -> { + state.copy(isUpdating = true, pendingError = null) + } + + is ConnectedAccountsReducerEvent.ErrorConsumed -> { + if (state.pendingError == + event.message + ) { + state.copy(pendingError = null) + } else { + state + } + } + is ConnectedAccountsReducerEvent.Loaded -> { - state.copy(isLoading = false, accounts = event.accounts) + state.copy(isLoading = false, errorMessage = null, accounts = event.accounts) } is ConnectedAccountsReducerEvent.LoadFailed -> { @@ -54,11 +83,11 @@ internal class ConnectedAccountsViewModel } is ConnectedAccountsReducerEvent.AccountsChanged -> { - state.copy(accounts = event.accounts) + state.copy(accounts = event.accounts, isUpdating = false) } is ConnectedAccountsReducerEvent.ChangeFailed -> { - state.copy(errorMessage = event.message) + state.copy(pendingError = event.message, isUpdating = false) } is ConnectedAccountsReducerEvent.LinkRequested -> { @@ -72,20 +101,24 @@ internal class ConnectedAccountsViewModel } private fun loadConnectedAccounts() { - viewModelScope.launch { - runCatchingCancellable { accountRepository.getConnectedAccounts() } - .onSuccess { accounts -> - dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) - }.onFailure { - dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) - } - } + if (loadJob?.isActive == true) return + dispatch(ConnectedAccountsReducerEvent.Loading) + loadJob = + viewModelScope.launch { + runCatchingCancellable { accountRepository.getConnectedAccounts() } + .onSuccess { accounts -> + dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) + }.onFailure { + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + } + } } private fun toggle( provider: String, enabled: Boolean, ) { + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return if (enabled) { dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider)) } else { @@ -97,6 +130,8 @@ internal class ConnectedAccountsViewModel provider: String, accessToken: String, ) { + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return + dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } @@ -105,6 +140,7 @@ internal class ConnectedAccountsViewModel } private fun unlink(provider: String) { + dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt new file mode 100644 index 0000000000..99198174b3 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt @@ -0,0 +1,68 @@ +package com.afternote.feature.setting.presentation.home + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class SettingUiState( + val profile: SettingProfileState = SettingProfileState.Loading, + val logoutCompleted: Unit? = null, + val withdraw: WithdrawUiState = WithdrawUiState.Idle, +) : UiState + +internal sealed interface SettingProfileState { + data object Loading : SettingProfileState + + data class Success( + val name: String, + val email: String, + ) : SettingProfileState + + /** 문구는 화면이 `setting_profile_load_error` 리소스로 그린다. 프로필 수정 화면과 같은 문장이다. */ + data object Error : SettingProfileState +} + +internal sealed interface WithdrawUiState { + data object Idle : WithdrawUiState + + data object Loading : WithdrawUiState + + data object Success : WithdrawUiState + + data object Error : WithdrawUiState +} + +internal sealed interface SettingIntent : MviIntent { + data object Refresh : SettingIntent + + data object Logout : SettingIntent + + data object ConsumeLogoutSuccess : SettingIntent + + data object DeleteAccount : SettingIntent + + data object DismissWithdrawError : SettingIntent +} + +internal sealed interface SettingReducerEvent : ReducerEvent { + data object ProfileLoading : SettingReducerEvent + + data class ProfileLoaded( + val name: String, + val email: String, + ) : SettingReducerEvent + + data object ProfileFailed : SettingReducerEvent + + data object LoggedOut : SettingReducerEvent + + data object LogoutConsumed : SettingReducerEvent + + data object Withdrawing : SettingReducerEvent + + data object Withdrawn : SettingReducerEvent + + data object WithdrawFailed : SettingReducerEvent + + data object WithdrawErrorDismissed : SettingReducerEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index 0474ef8b65..9178b05dab 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -1,7 +1,10 @@ package com.afternote.feature.setting.presentation.home +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -26,12 +29,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.home.component.SettingProfile import com.afternote.feature.setting.presentation.home.component.SettingSection +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent import com.afternote.feature.setting.presentation.shared.component.SettingMenuItem import kotlinx.coroutines.launch @@ -59,19 +64,18 @@ internal fun SettingScreen( val unavailableMessage = stringResource(R.string.setting_menu_unavailable) val acknowledgeLabel = stringResource(R.string.setting_menu_acknowledge) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val logoutCompleted by viewModel.logoutCompleted.collectAsStateWithLifecycle() val currentOnLogoutSuccess by rememberUpdatedState(onLogoutSuccess) val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycleState by lifecycle.currentStateFlow.collectAsStateWithLifecycle() LaunchedEffect(lifecycleState) { if (lifecycleState == Lifecycle.State.RESUMED) { - viewModel.refresh() + viewModel.onIntent(SettingIntent.Refresh) } } - LaunchedEffect(logoutCompleted) { - if (logoutCompleted) { + uiState.logoutCompleted?.let { signal -> + ObserveSignal(signal, SettingIntent.ConsumeLogoutSuccess, viewModel::onIntent) { currentOnLogoutSuccess() } } @@ -88,8 +92,9 @@ internal fun SettingScreen( containerColor = Color.Transparent, ) { innerPadding -> SettingScreenContent( - uiState = uiState, - onLogoutClick = viewModel::logout, + uiState = uiState.profile, + onRetry = { viewModel.onIntent(SettingIntent.Refresh) }, + onLogoutClick = { viewModel.onIntent(SettingIntent.Logout) }, onUnavailableClick = { scope.launch { snackbarHostState.currentSnackbarData?.dismiss() @@ -114,7 +119,8 @@ internal fun SettingScreen( @Composable private fun SettingScreenContent( - uiState: SettingUiState, + uiState: SettingProfileState, + onRetry: () -> Unit, onLogoutClick: () -> Unit, onUnavailableClick: () -> Unit, onProfileEditClick: () -> Unit, @@ -146,23 +152,17 @@ private fun SettingScreenContent( ) } - when (val state = uiState) { - is SettingUiState.Loading -> { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularProgressIndicator() + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + when (val state = uiState) { + SettingProfileState.Loading -> { + Box(Modifier.fillMaxWidth().height(160.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } - } - is SettingUiState.Success -> { - Column( - modifier = - modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { + is SettingProfileState.Success -> { SettingProfile( name = state.name, email = state.email, @@ -170,101 +170,96 @@ private fun SettingScreenContent( onNoticeClick = onNoticeClick, onRecipientListClick = onRecipientListClick, ) + } - SettingSection(title = stringResource(R.string.setting_section_account)) { - SettingMenuItem( - label = stringResource(R.string.setting_account_profile_edit), - onClick = onProfileEditClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_password_change), - onClick = onPasswordChangeClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_linked_account), - onClick = onLinkedAccountClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_notification), - onClick = onNotificationClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_recipient)) { - SettingMenuItem( - label = stringResource(R.string.setting_recipient_list), - onClick = onRecipientListClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_recipient_register), - onClick = onRecipientRegisterClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_recipient_after_delivery), - onClick = onDeliveryConditionsClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_security)) { - SettingMenuItem( - label = stringResource(R.string.setting_security_passkey), - onClick = onPasskeyClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_security_app_lock), - onClick = onAppLockClick, - ) - } - - SettingSection(title = stringResource(R.string.setting_section_support)) { - SettingMenuItem( - label = stringResource(R.string.setting_support_faq), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_inquiry), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_notice), - onClick = onNoticeClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_terms), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_privacy), - onClick = onUnavailableClick, - ) - SettingMenuItem( - label = stringResource(R.string.setting_support_service_info), - onClick = onUnavailableClick, - ) - } - - SettingMenuItem( - label = stringResource(R.string.setting_logout), - onClick = { showLogoutDialog = true }, - modifier = Modifier.padding(top = 8.dp), - ) - SettingMenuItem( - label = stringResource(R.string.setting_account_withdraw), - onClick = onWithdrawGuideClick, + SettingProfileState.Error -> { + SettingLoadErrorContent( + message = stringResource(R.string.setting_profile_load_error), + onRetry = onRetry, + modifier = Modifier.height(160.dp), ) } } + SettingSection(title = stringResource(R.string.setting_section_account)) { + SettingMenuItem( + label = stringResource(R.string.setting_account_profile_edit), + onClick = onProfileEditClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_password_change), + onClick = onPasswordChangeClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_linked_account), + onClick = onLinkedAccountClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_notification), + onClick = onNotificationClick, + ) + } - is SettingUiState.Error -> { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SettingMenuItem( - label = stringResource(R.string.setting_logout), - onClick = { showLogoutDialog = true }, - ) - } + SettingSection(title = stringResource(R.string.setting_section_recipient)) { + SettingMenuItem( + label = stringResource(R.string.setting_recipient_list), + onClick = onRecipientListClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_recipient_register), + onClick = onRecipientRegisterClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_recipient_after_delivery), + onClick = onDeliveryConditionsClick, + ) + } + + SettingSection(title = stringResource(R.string.setting_section_security)) { + SettingMenuItem( + label = stringResource(R.string.setting_security_passkey), + onClick = onPasskeyClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_security_app_lock), + onClick = onAppLockClick, + ) + } + + SettingSection(title = stringResource(R.string.setting_section_support)) { + SettingMenuItem( + label = stringResource(R.string.setting_support_faq), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_inquiry), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_notice), + onClick = onNoticeClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_terms), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_privacy), + onClick = onUnavailableClick, + ) + SettingMenuItem( + label = stringResource(R.string.setting_support_service_info), + onClick = onUnavailableClick, + ) } + + SettingMenuItem( + label = stringResource(R.string.setting_logout), + onClick = { showLogoutDialog = true }, + modifier = Modifier.padding(top = 8.dp), + ) + SettingMenuItem( + label = stringResource(R.string.setting_account_withdraw), + onClick = onWithdrawGuideClick, + ) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index e28c058206..6e5f405d83 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -1,47 +1,16 @@ package com.afternote.feature.setting.presentation.home -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.auth.AuthRepository +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingAccountRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject -sealed interface SettingUiState { - data object Loading : SettingUiState - - data class Success( - val name: String, - val email: String, - ) : SettingUiState - - data class Error( - val message: String, - ) : SettingUiState -} - -sealed interface WithdrawUiState { - data object Idle : WithdrawUiState - - data object Loading : WithdrawUiState - - data object Success : WithdrawUiState - - data object Error : WithdrawUiState -} - -/** - * 설정 화면용 ViewModel. - * - * 로그아웃 시 [AuthRepository.logout] 한 번 호출로 끝낸다. 내부적으로 서버 호출은 best-effort, - * 로컬 토큰은 결과 무관하게 정리하도록 위임돼 있다. 호출처는 토큰 정리 여부를 신경 쓸 필요 없음. - */ @HiltViewModel internal class SettingViewModel @Inject @@ -49,64 +18,99 @@ internal class SettingViewModel private val authRepository: AuthRepository, private val myProfileRepository: MyProfileRepository, private val accountRepository: SettingAccountRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(SettingUiState.Loading) - val uiState = _uiState.asStateFlow() - - private val _logoutCompleted = MutableStateFlow(false) - val logoutCompleted = _logoutCompleted.asStateFlow() + ) : MviViewModel(SettingUiState()) { + private var loadJob: Job? = null + private var logoutJob: Job? = null init { loadProfile() } - fun refresh() = loadProfile() - - private fun loadProfile() { - viewModelScope.launch { - runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { profile -> - _uiState.value = - SettingUiState.Success( - name = profile.name, - email = profile.email, - ) - }.onFailure { - _uiState.value = SettingUiState.Error("프로필을 불러올 수 없습니다.") - } + override fun onIntent(intent: SettingIntent) { + when (intent) { + SettingIntent.Refresh -> loadProfile() + SettingIntent.Logout -> logout() + SettingIntent.ConsumeLogoutSuccess -> dispatch(SettingReducerEvent.LogoutConsumed) + SettingIntent.DeleteAccount -> deleteAccount() + SettingIntent.DismissWithdrawError -> dispatch(SettingReducerEvent.WithdrawErrorDismissed) } } - fun logout() { - viewModelScope.launch { - // logout() 내부에서 서버 호출(best-effort) + 로컬 토큰 정리까지 처리한다. - authRepository.logout() - _logoutCompleted.value = true - } - } + override fun reduce( + state: SettingUiState, + event: SettingReducerEvent, + ): SettingUiState = + when (event) { + SettingReducerEvent.ProfileLoading -> { + if (state.profile is SettingProfileState.Success) state else state.copy(profile = SettingProfileState.Loading) + } - private val _withdrawUiState = MutableStateFlow(WithdrawUiState.Idle) - val withdrawUiState = _withdrawUiState.asStateFlow() + is SettingReducerEvent.ProfileLoaded -> { + state.copy(profile = SettingProfileState.Success(event.name, event.email)) + } - fun deleteAccount() { - if (_withdrawUiState.value == WithdrawUiState.Loading) return - _withdrawUiState.value = WithdrawUiState.Loading + SettingReducerEvent.ProfileFailed -> { + state.copy(profile = SettingProfileState.Error) + } - viewModelScope.launch { - try { - accountRepository.deleteAccount() - _withdrawUiState.value = WithdrawUiState.Success - } catch (exception: CancellationException) { - throw exception - } catch (_: Exception) { - _withdrawUiState.value = WithdrawUiState.Error + SettingReducerEvent.LoggedOut -> { + state.copy(logoutCompleted = Unit) + } + + SettingReducerEvent.LogoutConsumed -> { + state.copy(logoutCompleted = null) + } + + SettingReducerEvent.Withdrawing -> { + state.copy(withdraw = WithdrawUiState.Loading) + } + + SettingReducerEvent.Withdrawn -> { + state.copy(withdraw = WithdrawUiState.Success) + } + + SettingReducerEvent.WithdrawFailed -> { + state.copy(withdraw = WithdrawUiState.Error) + } + + SettingReducerEvent.WithdrawErrorDismissed -> { + if (state.withdraw == + WithdrawUiState.Error + ) { + state.copy(withdraw = WithdrawUiState.Idle) + } else { + state + } } } + + private fun loadProfile() { + if (loadJob?.isActive == true) return + loadJob = + viewModelScope.launch { + dispatch(SettingReducerEvent.ProfileLoading) + runCatchingCancellable { myProfileRepository.getMyProfile() } + .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email)) } + .onFailure { dispatch(SettingReducerEvent.ProfileFailed) } + } } - fun dismissWithdrawError() { - if (_withdrawUiState.value == WithdrawUiState.Error) { - _withdrawUiState.value = WithdrawUiState.Idle + private fun logout() { + if (logoutJob?.isActive == true || currentState.logoutCompleted != null) return + logoutJob = + viewModelScope.launch { + authRepository.logout() + dispatch(SettingReducerEvent.LoggedOut) + } + } + + private fun deleteAccount() { + if (currentState.withdraw == WithdrawUiState.Loading || currentState.withdraw == WithdrawUiState.Success) return + dispatch(SettingReducerEvent.Withdrawing) + viewModelScope.launch { + runCatchingCancellable { accountRepository.deleteAccount() } + .onSuccess { dispatch(SettingReducerEvent.Withdrawn) } + .onFailure { dispatch(SettingReducerEvent.WithdrawFailed) } } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt index ea165e8839..67f490d968 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawConfirmScreen.kt @@ -1,10 +1,8 @@ package com.afternote.feature.setting.presentation.home import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType import com.afternote.feature.setting.presentation.R @@ -17,9 +15,9 @@ internal fun WithdrawConfirmScreen( viewModel: SettingViewModel, modifier: Modifier = Modifier, ) { - val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() - val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() - val withdrawUiState by viewModel.withdrawUiState.collectAsStateWithLifecycle() + val userName = (uiState.profile as? SettingProfileState.Success)?.name.orEmpty() + val userEmail = (uiState.profile as? SettingProfileState.Success)?.email.orEmpty() + val withdrawUiState = uiState.withdraw when (withdrawUiState) { WithdrawUiState.Success -> { @@ -38,8 +36,8 @@ internal fun WithdrawConfirmScreen( message = stringResource(R.string.setting_withdraw_failed_message), confirmText = stringResource(R.string.setting_withdraw_retry_button), dismissText = stringResource(R.string.setting_withdraw_close_button), - onConfirm = viewModel::deleteAccount, - onDismiss = viewModel::dismissWithdrawError, + onConfirm = { viewModel.onIntent(SettingIntent.DeleteAccount) }, + onDismiss = { viewModel.onIntent(SettingIntent.DismissWithdrawError) }, ) } @@ -52,7 +50,7 @@ internal fun WithdrawConfirmScreen( userName = userName, userEmail = userEmail, onBackClick = onBackClick, - onWithdrawClick = viewModel::deleteAccount, + onWithdrawClick = { viewModel.onIntent(SettingIntent.DeleteAccount) }, isLoading = withdrawUiState == WithdrawUiState.Loading, modifier = modifier, ) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt index 3916385035..c5204986d0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/WithdrawGuideScreen.kt @@ -35,15 +35,15 @@ import com.afternote.feature.setting.presentation.R // 설정 - 회원 탈퇴 안내 @Composable -fun WithdrawGuideScreen( +internal fun WithdrawGuideScreen( uiState: SettingUiState, onBackClick: () -> Unit, onCancelClick: () -> Unit, onConfirmClick: () -> Unit, modifier: Modifier = Modifier, ) { - val userName = (uiState as? SettingUiState.Success)?.name.orEmpty() - val userEmail = (uiState as? SettingUiState.Success)?.email.orEmpty() + val userName = (uiState.profile as? SettingProfileState.Success)?.name.orEmpty() + val userEmail = (uiState.profile as? SettingProfileState.Success)?.email.orEmpty() var agreed by remember { mutableStateOf(false) } Scaffold( topBar = { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt index 231273c3c7..d2fdd7c93e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContent.kt @@ -1,8 +1,10 @@ package com.afternote.feature.setting.presentation.notification +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -12,10 +14,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.asString +import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.notification.component.PushToggleSection +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent /** * 푸시 알림 설정 화면의 상태 없는 본문. @@ -31,6 +36,7 @@ internal fun PushNotificationContent( onNewsletterToggle: (Boolean) -> Unit, onMindRecordToggle: (Boolean) -> Unit, onAfternoteToggle: (Boolean) -> Unit, + onRetry: () -> Unit, ) { Scaffold( topBar = { @@ -48,12 +54,28 @@ internal fun PushNotificationContent( .padding(padding) .padding(horizontal = 20.dp, vertical = 16.dp), ) { - PushToggleSection( - uiState = uiState, - onNewsletterToggle = onNewsletterToggle, - onMindRecordToggle = onMindRecordToggle, - onAfternoteToggle = onAfternoteToggle, - ) + when { + uiState.isLoading -> { + LoadingBody(modifier = Modifier.weight(1f)) + } + + uiState.errorMessage != null -> { + SettingLoadErrorContent( + message = uiState.errorMessage.asString(), + onRetry = onRetry, + modifier = Modifier.weight(1f), + ) + } + + else -> { + PushToggleSection( + uiState = uiState, + onNewsletterToggle = onNewsletterToggle, + onMindRecordToggle = onMindRecordToggle, + onAfternoteToggle = onAfternoteToggle, + ) + } + } Spacer(Modifier.height(24.dp)) Text( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt index 0ac593a7a6..3ad1bf42a0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -6,6 +6,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface PushNotificationIntent : MviIntent { + data object RetryLoad : PushNotificationIntent + /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ data object RefreshDeviceAlarmStatus : PushNotificationIntent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index ae9c1ec4e2..a0e68dfe4e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -17,6 +17,7 @@ internal fun PushNotificationScreen( PushNotificationContent( uiState = uiState, onBack = onBack, + onRetry = { viewModel.onIntent(PushNotificationIntent.RetryLoad) }, onNewsletterToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, it)) }, onMindRecordToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.MIND_RECORD, it)) }, onAfternoteToggle = { viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.AFTERNOTE, it)) }, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index adcf6fd7a7..71c9a1f277 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -1,9 +1,11 @@ package com.afternote.feature.setting.presentation.notification +import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.UiState internal data class PushNotificationUiState( val isLoading: Boolean = false, + val errorMessage: UiText? = null, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) val isSmsChecked: Boolean = true, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index da43aa7fcd..de78ce49bf 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -7,11 +7,14 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.error.PushSettingFailure +import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.domain.SettingNotificationRepository +import com.afternote.feature.setting.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.launch @HiltViewModel @@ -21,7 +24,11 @@ internal class PushNotificationViewModel @ApplicationContext private val context: Context, private val notificationRepository: SettingNotificationRepository, private val errorReporter: ErrorReporter, - ) : MviViewModel(PushNotificationUiState()) { + ) : MviViewModel( + PushNotificationUiState(isLoading = true), + ) { + private var loadJob: Job? = null + init { refreshDeviceAlarmStatus() loadPushSettings() @@ -30,6 +37,10 @@ internal class PushNotificationViewModel override fun onIntent(intent: PushNotificationIntent) { when (intent) { + PushNotificationIntent.RetryLoad -> { + if (currentState.errorMessage != null) loadPushSettings() + } + PushNotificationIntent.RefreshDeviceAlarmStatus -> { refreshDeviceAlarmStatus() } @@ -74,12 +85,13 @@ internal class PushNotificationViewModel } PushNotificationReducerEvent.PushSettingsLoading -> { - state.copy(isLoading = true) + state.copy(isLoading = true, errorMessage = null) } is PushNotificationReducerEvent.PushSettingsLoaded -> { state.copy( isLoading = false, + errorMessage = null, isNewsletterOn = event.setting.timeLetter, isMindRecordOn = event.setting.mindRecord, isAfternoteOn = event.setting.afterNote, @@ -87,7 +99,7 @@ internal class PushNotificationViewModel } PushNotificationReducerEvent.PushSettingsLoadFailed -> { - state.copy(isLoading = false) + state.copy(isLoading = false, errorMessage = UiText.Resource(R.string.setting_push_load_error)) } is PushNotificationReducerEvent.MarketingConsentsLoaded -> { @@ -148,19 +160,21 @@ internal class PushNotificationViewModel } private fun loadPushSettings() { - viewModelScope.launch { - Log.d(TAG, "loadPushSettings: start") - dispatch(PushNotificationReducerEvent.PushSettingsLoading) - runCatchingCancellable { notificationRepository.getMyPushSettings() } - .onSuccess { setting -> - Log.d(TAG, "loadPushSettings: success=$setting") - dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) - }.onFailure { e -> - // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). - Log.e(TAG, "loadPushSettings: failed", e) - dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) - } - } + if (loadJob?.isActive == true) return + dispatch(PushNotificationReducerEvent.PushSettingsLoading) + loadJob = + viewModelScope.launch { + Log.d(TAG, "loadPushSettings: start") + runCatchingCancellable { notificationRepository.getMyPushSettings() } + .onSuccess { setting -> + Log.d(TAG, "loadPushSettings: success=$setting") + dispatch(PushNotificationReducerEvent.PushSettingsLoaded(setting)) + }.onFailure { e -> + // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). + Log.e(TAG, "loadPushSettings: failed", e) + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + } + } } private fun loadMarketingConsents() { @@ -204,7 +218,7 @@ internal class PushNotificationViewModel } private fun updatePushSetting(update: PushSettingUpdate) { - if (currentState.isUpdating(update.setting)) return + if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating(update.setting)) return val previousValue = currentState.valueOf(update.setting) dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt index a6001854a8..bdb8ee77cf 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt @@ -4,6 +4,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ProfileEditIntent : MviIntent { + data object RetryLoad : ProfileEditIntent + data class SelectPhoto( val uri: String, ) : ProfileEditIntent @@ -20,6 +22,8 @@ internal sealed interface ProfileEditIntent : MviIntent { } internal sealed interface ProfileEditReducerEvent : ReducerEvent { + data object Loading : ProfileEditReducerEvent + data class Loaded( val name: String, val phone: String, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 7100002cee..9907ed70ac 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -19,12 +19,15 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -45,6 +48,8 @@ import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone +import com.afternote.feature.setting.presentation.shared.component.SettingLoadErrorContent +import kotlinx.coroutines.launch @Composable internal fun ProfileEditScreen( @@ -54,6 +59,9 @@ internal fun ProfileEditScreen( viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val feedbackScope = rememberCoroutineScope() + val updateFailedMessage = stringResource(R.string.setting_profile_update_error) val onPickProfileImage = rememberProfileImagePicker( canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false, @@ -68,13 +76,19 @@ internal fun ProfileEditScreen( onIntent = viewModel::onIntent, ) { event -> when (event) { - ProfileEditEvent.UpdateSuccess -> onBackClick() - ProfileEditEvent.UpdateFailure -> Unit + ProfileEditEvent.UpdateSuccess -> { + onBackClick() + } + + ProfileEditEvent.UpdateFailure -> { + feedbackScope.launch { snackbarHostState.showSnackbar(updateFailedMessage) } + } } } } ProfileEditContent( + snackbarHostState = snackbarHostState, uiState = uiState, onPickImageClick = onPickProfileImage, onIntent = viewModel::onIntent, @@ -86,6 +100,7 @@ internal fun ProfileEditScreen( @Composable private fun ProfileEditContent( + snackbarHostState: SnackbarHostState, uiState: ProfileEditUiState, onPickImageClick: () -> Unit, onIntent: (ProfileEditIntent) -> Unit, @@ -102,6 +117,7 @@ private fun ProfileEditContent( }, modifier = modifier, containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> when (val state = uiState) { is ProfileEditUiState.Loading -> { @@ -127,15 +143,11 @@ private fun ProfileEditContent( } is ProfileEditUiState.Error -> { - Box( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - Text(text = "프로필을 불러올 수 없습니다.") - } + SettingLoadErrorContent( + message = stringResource(R.string.setting_profile_load_error), + onRetry = { onIntent(ProfileEditIntent.RetryLoad) }, + modifier = Modifier.padding(innerPadding), + ) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index bc71680f77..782c1d5ffd 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -8,6 +8,7 @@ import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverPhoneValidation import com.afternote.feature.setting.presentation.receiver.validateReceiverPhone import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,12 +21,15 @@ internal class ProfileEditViewModel private val myProfileRepository: MyProfileRepository, private val photoUploadRepository: PhotoUploadRepository, ) : MviViewModel(ProfileEditUiState.Loading) { + private var loadJob: Job? = null + init { loadProfile() } override fun onIntent(intent: ProfileEditIntent) { when (intent) { + ProfileEditIntent.RetryLoad -> if (currentState == ProfileEditUiState.Error) loadProfile() is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri)) is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) is ProfileEditIntent.ConsumeEvent -> dispatch(ProfileEditReducerEvent.EventConsumed(intent.event)) @@ -37,6 +41,10 @@ internal class ProfileEditViewModel event: ProfileEditReducerEvent, ): ProfileEditUiState = when (event) { + ProfileEditReducerEvent.Loading -> { + ProfileEditUiState.Loading + } + is ProfileEditReducerEvent.Loaded -> { ProfileEditUiState.Success( name = event.name, @@ -73,21 +81,24 @@ internal class ProfileEditViewModel } private fun loadProfile() { - viewModelScope.launch { - runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { user -> - dispatch( - ProfileEditReducerEvent.Loaded( - name = user.name, - phone = user.phone.orEmpty(), - email = user.email, - profileImageUrl = user.profileImageUrl, - ), - ) - }.onFailure { - dispatch(ProfileEditReducerEvent.LoadFailed) - } - } + if (loadJob?.isActive == true) return + dispatch(ProfileEditReducerEvent.Loading) + loadJob = + viewModelScope.launch { + runCatchingCancellable { myProfileRepository.getMyProfile() } + .onSuccess { user -> + dispatch( + ProfileEditReducerEvent.Loaded( + name = user.name, + phone = user.phone.orEmpty(), + email = user.email, + profileImageUrl = user.profileImageUrl, + ), + ) + }.onFailure { + dispatch(ProfileEditReducerEvent.LoadFailed) + } + } } private fun updateProfile( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt new file mode 100644 index 0000000000..a742b55256 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/shared/component/SettingLoadErrorContent.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.setting.presentation.shared.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.setting.presentation.R + +@Composable +internal fun SettingLoadErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize().padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.setting_load_retry)) + } + } +} diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c581..5733f63bb8 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -181,4 +181,8 @@ 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. 네트워크 연결을 확인해주세요. 비밀번호를 변경하지 못했습니다. 입력하신 내용을 확인해 주세요. + 프로필을 불러올 수 없습니다. + 프로필 수정에 실패했습니다. 다시 시도해 주세요. + 알림 설정을 불러올 수 없습니다. + 다시 시도 diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt index 680fa0fba8..cca855c10c 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTest.kt @@ -24,10 +24,11 @@ internal fun connectedAccountsScreenCompactScreenshot() { } @Composable -private fun ConnectedAccountsScreenScreenshotContent() { +private fun ConnectedAccountsScreenScreenshotContent(uiState: ConnectedAccountsUiState = connectedAccountsPreviewState) { AfternoteTheme { ConnectedAccountsContent( - uiState = connectedAccountsPreviewState, + onRetry = {}, + uiState = uiState, snackbarHostState = remember { SnackbarHostState() }, onBack = {}, onToggle = { _, _ -> }, @@ -68,3 +69,19 @@ private val connectedAccountsPreviewState = ), ), ) + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun connectedAccountsLoadingScreenshot() { + ConnectedAccountsScreenScreenshotContent(ConnectedAccountsUiState(isLoading = true)) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun connectedAccountsLoadErrorScreenshot() { + ConnectedAccountsScreenScreenshotContent(ConnectedAccountsUiState(errorMessage = "계정 정보를 불러올 수 없습니다.")) +} diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt index d3168b6c7b..07e49c4ead 100644 --- a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTest.kt @@ -2,8 +2,10 @@ package com.afternote.feature.setting.presentation.notification import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.UiText import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.setting.presentation.R import com.android.tools.screenshot.PreviewTest @PreviewTest @@ -21,10 +23,11 @@ internal fun pushNotificationCompactScreenshot() { } @Composable -private fun PushNotificationScreenshotContent() { +private fun PushNotificationScreenshotContent(uiState: PushNotificationUiState = PushNotificationUiState(isAfternoteOn = true)) { AfternoteTheme { PushNotificationContent( - uiState = PushNotificationUiState(isAfternoteOn = true), + onRetry = {}, + uiState = uiState, onBack = {}, onNewsletterToggle = {}, onMindRecordToggle = {}, @@ -32,3 +35,19 @@ private fun PushNotificationScreenshotContent() { ) } } + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun pushNotificationLoadingScreenshot() { + PushNotificationScreenshotContent(PushNotificationUiState(isLoading = true)) +} + +@PreviewTest +@Preview(showBackground = true) +@Preview(showBackground = true, device = COMPACT_DEVICE_SPEC) +@Composable +internal fun pushNotificationLoadErrorScreenshot() { + PushNotificationScreenshotContent(PushNotificationUiState(errorMessage = UiText.Resource(R.string.setting_push_load_error))) +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index bc2d64031d..6de740f106 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.domain.testing.FakeMyProfileRepository.ProfileUpdateCall @@ -34,7 +35,6 @@ import com.afternote.feature.setting.presentation.applock.PinSetupStep import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel -import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawConfirmScreen import com.afternote.feature.setting.presentation.home.WithdrawUiState @@ -131,7 +131,7 @@ class SettingAccountSecurityTest { linkRepository.onLinkConnectedAccount = { _, _ -> throw IllegalStateException("oauth rejected") } composeRule.runOnIdle { linkViewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "google-token")) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - linkViewModel.uiState.value.errorMessage == "계정 연결에 실패했습니다." + linkViewModel.uiState.value.pendingError == "계정 연결에 실패했습니다." } assertEquals( @@ -156,7 +156,7 @@ class SettingAccountSecurityTest { } assertEquals(listOf("google"), unlinkRepository.connectedUnlinkCalls) - assertEquals("계정 연결 해제에 실패했습니다.", unlinkViewModel.uiState.value.errorMessage) + assertEquals("계정 연결 해제에 실패했습니다.", unlinkViewModel.uiState.value.pendingError) } @Test @@ -313,7 +313,7 @@ class SettingAccountSecurityTest { composeRule.setContent { AfternoteTheme { WithdrawConfirmScreen( - uiState = SettingUiState.Success(name = DEFAULT_USER.name, email = DEFAULT_USER.email), + uiState = viewModel.uiState.collectAsStateWithLifecycle().value, onBackClick = {}, onWithdrawSuccess = { successCalls += 1 }, viewModel = viewModel, @@ -332,7 +332,7 @@ class SettingAccountSecurityTest { } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Error + viewModel.uiState.value.withdraw == WithdrawUiState.Error } composeRule .onNodeWithText("회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.") diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index a889535548..e4cb44880b 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -44,6 +44,8 @@ import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewM import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.home.SettingIntent +import com.afternote.feature.setting.presentation.home.SettingProfileState import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.home.WithdrawUiState @@ -643,28 +645,28 @@ class SettingCompletionTest { } val viewModel = SettingViewModel(authRepository, profileRepository, repository) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.uiState.value is SettingUiState.Success + viewModel.uiState.value.profile is SettingProfileState.Success } - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deleteAccountCalls == 1 } - assertEquals(WithdrawUiState.Loading, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Loading, viewModel.uiState.value.withdraw) firstGate.completeExceptionally(IllegalStateException("서버가 503 으로 거절했다")) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Error + viewModel.uiState.value.withdraw == WithdrawUiState.Error } - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { repository.deleteAccountCalls == 2 } retryGate.complete(Unit) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - viewModel.withdrawUiState.value == WithdrawUiState.Success + viewModel.uiState.value.withdraw == WithdrawUiState.Success } assertEquals(2, repository.deleteAccountCalls) } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt index 4f78f1ac52..a2a78bae53 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCoroutineCancellationTest.kt @@ -15,6 +15,7 @@ import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingProfileState import com.afternote.feature.setting.presentation.home.SettingUiState import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.notification.PushNotificationIntent @@ -238,7 +239,7 @@ class SettingCoroutineCancellationTest { runCurrent() assertTrue(pending.isCancelled) - assertEquals(SettingUiState.Loading, viewModel.uiState.value) + assertEquals(SettingProfileState.Loading, viewModel.uiState.value.profile) } @Test @@ -326,7 +327,7 @@ class SettingCoroutineCancellationTest { ) runCurrent() - assertEquals(SettingUiState.Error("프로필을 불러올 수 없습니다."), viewModel.uiState.value) + assertEquals(SettingProfileState.Error, viewModel.uiState.value.profile) } private fun pushViewModel( diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt index 3e40f3677a..adac4e7fb9 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingFlowTest.kt @@ -14,6 +14,7 @@ import com.afternote.core.domain.testing.FakeMyProfileRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.home.SettingIntent import com.afternote.feature.setting.presentation.home.SettingScreen import com.afternote.feature.setting.presentation.home.SettingViewModel import com.afternote.feature.setting.presentation.notification.PushNotificationIntent @@ -34,6 +35,30 @@ class SettingFlowTest { @get:Rule val composeRule = createComposeRule() + @Test + fun profileLoadFailureKeepsSettingsEntriesAndRetryRecoversProfile() { + var reads = 0 + var offline = true + val profile = + com.afternote.core.domain.testing.FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + reads++ + if (offline) throw java.io.IOException("offline") + com.afternote.core.model.user + .User("복구 사용자", "test@example.com", null, null) + } + } + val viewModel = SettingViewModel(settingFlowAuthRepository(loggedIn = true), profile, settingFlowAccountRepository()) + setSettingContent(viewModel) + composeRule.onNodeWithText("프로필을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("프로필 수정").assertIsDisplayed() + val failedReads = reads + composeRule.runOnIdle { offline = false } + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.onNodeWithText("복구 사용자").assertIsDisplayed() + assertEquals(failedReads + 1, reads) + } + @Test fun profileAndSecurityEntries_emitExpectedNavigation() { val auth = settingFlowAuthRepository(loggedIn = true) @@ -85,7 +110,7 @@ class SettingFlowTest { composeRule.setContent { AfternoteTheme {} } assertEquals(0, account.deleteAccountCalls) - composeRule.runOnIdle { viewModel.deleteAccount() } + composeRule.runOnIdle { viewModel.onIntent(SettingIntent.DeleteAccount) } composeRule.waitUntil(timeoutMillis = 5_000) { account.deleteAccountCalls == 1 } assertEquals(1, account.deleteAccountCalls) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt new file mode 100644 index 0000000000..80e9b32004 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadFeedbackTest.kt @@ -0,0 +1,111 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.UiText +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsContent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsScreen +import com.afternote.feature.setting.presentation.account.ConnectedAccountsUiState +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.notification.PushNotificationContent +import com.afternote.feature.setting.presentation.notification.PushNotificationUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent +import com.afternote.feature.setting.presentation.profile.ProfileEditScreen +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class SettingLoadFeedbackTest { + @get:Rule val composeRule = createComposeRule() + + @Test fun pushLoadErrorHidesSwitchesAndExposesRetry() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { + PushNotificationContent( + uiState = PushNotificationUiState(errorMessage = UiText.Resource(R.string.setting_push_load_error)), + onBack = {}, + onNewsletterToggle = {}, + onMindRecordToggle = {}, + onAfternoteToggle = {}, + onRetry = { retries++ }, + ) + } + } + composeRule.onNodeWithText("알림 설정을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("뉴스레터").assertDoesNotExist() + composeRule.onNodeWithText("다시 시도").performClick() + assertEquals(1, retries) + } + + @Test fun accountLoadErrorExposesRetryInsteadOfAnEmptyAccountList() { + var retries = 0 + composeRule.setContent { + AfternoteTheme { + ConnectedAccountsContent( + uiState = ConnectedAccountsUiState(errorMessage = "계정 정보를 불러올 수 없습니다."), + snackbarHostState = SnackbarHostState(), + onBack = {}, + onToggle = { _, _ -> }, + onRetry = { retries++ }, + ) + } + } + composeRule.onNodeWithText("계정 정보를 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").performClick() + assertEquals(1, retries) + } + + @Test fun profileLoadRetryRecoversAndSaveFailureIsVisible() { + var reads = 0 + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else User("테스트", "test@example.com", "01012345678", null) } + onUpdateMyProfile = { _, _, _ -> throw IOException() } + } + val vm = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + composeRule.setContent { AfternoteTheme { ProfileEditScreen({}, {}, viewModel = vm) } } + composeRule.onNodeWithText("프로필을 불러올 수 없습니다.").assertIsDisplayed() + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.waitUntil(5_000) { vm.uiState.value is ProfileEditUiState.Success } + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText("수정하기")) + composeRule.onNodeWithText("수정하기").performClick() + composeRule.onNodeWithText("프로필 수정에 실패했습니다. 다시 시도해 주세요.").assertIsDisplayed() + assertEquals(2, reads) + } + + @Test fun unlinkFailureShowsFeedbackAndKeepsAccountRows() { + val repository = FakeSettingAccountRepository().apply { onUnlinkConnectedAccount = { throw IOException() } } + val vm = ConnectedAccountsViewModel(repository) + composeRule.setContent { AfternoteTheme { ConnectedAccountsScreen({}, viewModel = vm) } } + composeRule.waitUntil(5_000) { !vm.uiState.value.isLoading } + composeRule.runOnIdle { vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) } + composeRule.onNodeWithText("계정 연결 해제에 실패했습니다.").assertIsDisplayed() + assertEquals(4, vm.uiState.value.accounts.size) + assertEquals(null, vm.uiState.value.errorMessage) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt new file mode 100644 index 0000000000..43ee6a4476 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingLoadRecoveryTest.kt @@ -0,0 +1,184 @@ +package com.afternote.feature.setting.presentation + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.home.SettingIntent +import com.afternote.feature.setting.presentation.home.SettingProfileState +import com.afternote.feature.setting.presentation.home.SettingViewModel +import com.afternote.feature.setting.presentation.home.WithdrawUiState +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingLoadRecoveryTest { + private val dispatcher = StandardTestDispatcher() + private val profile = User("테스트", "test@example.com", "01012345678", null) + + @Before fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After fun tearDown() { + Dispatchers.resetMain() + } + + @Test fun `home retry recovers profile without resetting account operation`() = + runTest(dispatcher) { + var reads = 0 + val gate = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else gate.await() } + } + val vm = SettingViewModel(FakeAuthRepository.strict(), repository, FakeSettingAccountRepository()) + runCurrent() + assertEquals(SettingProfileState.Error, vm.uiState.value.profile) + vm.onIntent(SettingIntent.DeleteAccount) + runCurrent() + vm.onIntent(SettingIntent.Refresh) + vm.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(2, reads) + assertEquals(WithdrawUiState.Success, vm.uiState.value.withdraw) + gate.complete(profile) + runCurrent() + assertEquals(SettingProfileState.Success(profile.name, profile.email), vm.uiState.value.profile) + assertEquals(WithdrawUiState.Success, vm.uiState.value.withdraw) + } + + @Test fun `profile retry is single flight and failed load cannot submit a patch`() = + runTest(dispatcher) { + var reads = 0 + val gate = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) throw IOException() else gate.await() } + } + val vm = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + assertEquals(ProfileEditUiState.Error, vm.uiState.value) + vm.onIntent(ProfileEditIntent.UpdateProfile("new", "01012345678")) + assertTrue(repository.profileUpdateCalls.isEmpty()) + vm.onIntent(ProfileEditIntent.RetryLoad) + vm.onIntent(ProfileEditIntent.RetryLoad) + assertEquals(ProfileEditUiState.Loading, vm.uiState.value) + runCurrent() + assertEquals(2, reads) + gate.complete(profile) + runCurrent() + assertEquals(profile.phone, (vm.uiState.value as ProfileEditUiState.Success).phone) + vm.onIntent(ProfileEditIntent.RetryLoad) + runCurrent() + assertEquals(2, reads) + } + + @Test fun `account load failure blocks writes and retry restores rows`() = + runTest(dispatcher) { + var fail = true + val repository = + FakeSettingAccountRepository().apply { + onGetConnectedAccounts = { if (fail) throw IOException() else connectedAccounts } + } + val vm = ConnectedAccountsViewModel(repository) + runCurrent() + assertNotNull(vm.uiState.value.errorMessage) + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + vm.onIntent(ConnectedAccountsIntent.Link("kakao", "unused")) + runCurrent() + assertTrue(repository.connectedUnlinkCalls.isEmpty()) + assertTrue(repository.connectedLinkCalls.isEmpty()) + fail = false + vm.onIntent(ConnectedAccountsIntent.RetryLoad) + vm.onIntent(ConnectedAccountsIntent.RetryLoad) + runCurrent() + assertEquals(2, repository.getConnectedAccountsCalls) + assertNull(vm.uiState.value.errorMessage) + assertEquals(4, vm.uiState.value.accounts.size) + } + + @Test fun `account write failure preserves rows and consumes feedback independently of load state`() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository().apply { onUnlinkConnectedAccount = { throw IOException() } } + val vm = ConnectedAccountsViewModel(repository) + runCurrent() + val rows = vm.uiState.value.accounts + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + vm.onIntent(ConnectedAccountsIntent.Toggle("kakao", false)) + runCurrent() + assertEquals(listOf("kakao"), repository.connectedUnlinkCalls) + assertEquals(rows, vm.uiState.value.accounts) + assertNull(vm.uiState.value.errorMessage) + val error = requireNotNull(vm.uiState.value.pendingError) + vm.onIntent(ConnectedAccountsIntent.ConsumeError("another message")) + assertEquals(error, vm.uiState.value.pendingError) + vm.onIntent(ConnectedAccountsIntent.ConsumeError(error)) + assertNull(vm.uiState.value.pendingError) + } + + @Test fun `push load error cannot save default values and retry reads real settings`() = + runTest(dispatcher) { + var reads = 0 + var writes = 0 + var fail = true + val repository = + FakeSettingNotificationRepository().apply { + onGetMyPushSettings = { + reads++ + if (fail) throw IOException() else UserPushSetting(timeLetter = true, mindRecord = true, afterNote = false) + } + onUpdateMyPushSettings = { _, _, _ -> + writes++ + UserPushSetting(false, false, false) + } + } + val vm = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + assertTrue(vm.uiState.value.isLoading) + vm.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + runCurrent() + assertNotNull(vm.uiState.value.errorMessage) + vm.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + runCurrent() + assertEquals(0, writes) + fail = false + vm.onIntent(PushNotificationIntent.RetryLoad) + vm.onIntent(PushNotificationIntent.RetryLoad) + runCurrent() + assertEquals(2, reads) + assertNull(vm.uiState.value.errorMessage) + assertTrue(vm.uiState.value.isNewsletterOn) + assertTrue(vm.uiState.value.isMindRecordOn) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt index 6baf72799c..c2eb7d27ab 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsMviTest.kt @@ -100,7 +100,7 @@ class ConnectedAccountsMviTest { repeat(2) { viewModel.onIntent(ConnectedAccountsIntent.Link(provider = "google", accessToken = "token-$it")) - assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.errorMessage) + assertEquals("계정 연결에 실패했습니다.", viewModel.uiState.value.pendingError) assertFalse( viewModel.uiState.value.accounts .first { it.provider == "google" } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt index f1582f606a..eee0ea0263 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModelTest.kt @@ -35,11 +35,11 @@ class SettingViewModelTest { runTest(dispatcher) { val viewModel = viewModel(onDeleteAccount = {}) - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) - assertEquals(WithdrawUiState.Loading, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Loading, viewModel.uiState.value.withdraw) runCurrent() - assertEquals(WithdrawUiState.Success, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Success, viewModel.uiState.value.withdraw) } @Test @@ -47,12 +47,12 @@ class SettingViewModelTest { runTest(dispatcher) { val viewModel = viewModel(onDeleteAccount = { throw IllegalStateException("failure") }) - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) runCurrent() - assertEquals(WithdrawUiState.Error, viewModel.withdrawUiState.value) - viewModel.dismissWithdrawError() - assertEquals(WithdrawUiState.Idle, viewModel.withdrawUiState.value) + assertEquals(WithdrawUiState.Error, viewModel.uiState.value.withdraw) + viewModel.onIntent(SettingIntent.DismissWithdrawError) + assertEquals(WithdrawUiState.Idle, viewModel.uiState.value.withdraw) } @Test @@ -61,8 +61,8 @@ class SettingViewModelTest { var requestCount = 0 val viewModel = viewModel(onDeleteAccount = { requestCount++ }) - viewModel.deleteAccount() - viewModel.deleteAccount() + viewModel.onIntent(SettingIntent.DeleteAccount) + viewModel.onIntent(SettingIntent.DeleteAccount) runCurrent() assertEquals(1, requestCount) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 75710a336b..68d4d7a0f2 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -557,7 +557,6 @@ class MviContractKonsistTest { private val ISSUE_1805_SETTING = setOf( "com.afternote.feature.setting.presentation.applock.AppLockSetupViewModel", - "com.afternote.feature.setting.presentation.home.SettingViewModel", "com.afternote.feature.setting.presentation.passkey.PassKeyViewModel", "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel", ) From 23bb6579df8f385c780e77c3513f3e56488d13ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:57:46 +0000 Subject: [PATCH 051/116] test: update screenshot baselines in CI --- ...edAccountsLoadErrorScreenshot_748aa731_0.png | Bin 0 -> 25034 bytes ...edAccountsLoadErrorScreenshot_c2abd7e6_0.png | Bin 0 -> 14760 bytes ...ctedAccountsLoadingScreenshot_748aa731_0.png | Bin 0 -> 20275 bytes ...ctedAccountsLoadingScreenshot_c2abd7e6_0.png | Bin 0 -> 11199 bytes ...tificationLoadErrorScreenshot_748aa731_0.png | Bin 0 -> 32053 bytes ...tificationLoadErrorScreenshot_c2abd7e6_0.png | Bin 0 -> 19910 bytes ...NotificationLoadingScreenshot_748aa731_0.png | Bin 0 -> 26864 bytes ...NotificationLoadingScreenshot_c2abd7e6_0.png | Bin 0 -> 16020 bytes 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_748aa731_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_c2abd7e6_0.png diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_748aa731_0.png new file mode 100644 index 0000000000000000000000000000000000000000..6852feed9e83dbdb9ef325d0785302f2ffb08d54 GIT binary patch literal 25034 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 nc_jd0ohqzX75;}XCHP(v_KzG%HyohMy~ literal 0 HcmV?d00001 diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadErrorScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000000000000000000000000000000000..74ab59ac8d6e29da32a982bd9541154bac943525 GIT binary patch literal 14760 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 literal 0 HcmV?d00001 diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreenScreenshotTestKt/connectedAccountsLoadingScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000000000000000000000000000000000..04fd32d7af59a3d13db5140c4f69983a0c341780 GIT binary patch literal 11199 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 literal 0 HcmV?d00001 diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_748aa731_0.png new file mode 100644 index 0000000000000000000000000000000000000000..236b3751984a0e51d7718dba67a533d83b3ba9d6 GIT binary patch literal 32053 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 literal 0 HcmV?d00001 diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadErrorScreenshot_c2abd7e6_0.png new file mode 100644 index 0000000000000000000000000000000000000000..42ec702aad1f4fcf14a50f8e57e700b2b4ca1214 GIT binary patch literal 19910 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 E55aoH2LJ#7 literal 0 HcmV?d00001 diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/notification/PushNotificationScreenScreenshotTestKt/pushNotificationLoadingScreenshot_748aa731_0.png new file mode 100644 index 0000000000000000000000000000000000000000..ff4783be9e1b614ecd4ea7a4b59c48d65fe406d6 GIT binary patch literal 26864 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 N;mqaJ6(?`r{V)IQym Date: Thu, 1 Oct 2026 20:58:00 +0900 Subject: [PATCH 052/116] refactor(app): remove unused navigation imports (#2175) --- .../afternote/afternote_fe/navigation/AppNavigationActions.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index dafdebc3ae..900bbb1ac3 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -8,13 +8,11 @@ import androidx.navigation.NavController import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomNavTab import com.afternote.feature.afternote.presentation.navigation.AfternoteExternalActions -import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.home.presentation.receiver.ReceiverHomeActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordNavActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import com.afternote.feature.onboarding.presentation.navigation.OnboardingExternalActions -import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute import com.afternote.feature.setting.presentation.navigation.SettingExternalActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterNavActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute From 8eb1ca4dee1a875fdcca67e1661abbe85095ab45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 21:00:39 +0900 Subject: [PATCH 053/116] fix(setting): show profile avatar from home state (#2225) --- feature/setting/presentation/build.gradle.kts | 1 + .../presentation/home/SettingContract.kt | 2 + .../presentation/home/SettingScreen.kt | 1 + .../presentation/home/SettingViewModel.kt | 4 +- .../home/component/SettingProfile.kt | 29 +++- .../home/SettingProfileAvatarTest.kt | 161 ++++++++++++++++++ .../home/SettingProfileImageTest.kt | 87 ++++++++++ 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt diff --git a/feature/setting/presentation/build.gradle.kts b/feature/setting/presentation/build.gradle.kts index 4eb2c8004c..9dc0847f81 100644 --- a/feature/setting/presentation/build.gradle.kts +++ b/feature/setting/presentation/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.compose.runtime) + implementation(libs.coil.compose) implementation(libs.kakao.sdk.auth) implementation(libs.kakao.sdk.user) implementation(libs.androidx.biometric) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt index 99198174b3..013ea36728 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingContract.kt @@ -16,6 +16,7 @@ internal sealed interface SettingProfileState { data class Success( val name: String, val email: String, + val profileImageUrl: String? = null, ) : SettingProfileState /** 문구는 화면이 `setting_profile_load_error` 리소스로 그린다. 프로필 수정 화면과 같은 문장이다. */ @@ -50,6 +51,7 @@ internal sealed interface SettingReducerEvent : ReducerEvent { data class ProfileLoaded( val name: String, val email: String, + val profileImageUrl: String?, ) : SettingReducerEvent data object ProfileFailed : SettingReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt index 9178b05dab..aece551f96 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingScreen.kt @@ -166,6 +166,7 @@ private fun SettingScreenContent( SettingProfile( name = state.name, email = state.email, + profileImageUrl = state.profileImageUrl, onInquiryClick = onUnavailableClick, onNoticeClick = onNoticeClick, onRecipientListClick = onRecipientListClick, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt index 6e5f405d83..234663954a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/SettingViewModel.kt @@ -46,7 +46,7 @@ internal class SettingViewModel } is SettingReducerEvent.ProfileLoaded -> { - state.copy(profile = SettingProfileState.Success(event.name, event.email)) + state.copy(profile = SettingProfileState.Success(event.name, event.email, event.profileImageUrl)) } SettingReducerEvent.ProfileFailed -> { @@ -90,7 +90,7 @@ internal class SettingViewModel viewModelScope.launch { dispatch(SettingReducerEvent.ProfileLoading) runCatchingCancellable { myProfileRepository.getMyProfile() } - .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email)) } + .onSuccess { dispatch(SettingReducerEvent.ProfileLoaded(it.name, it.email, it.profileImageUrl)) } .onFailure { dispatch(SettingReducerEvent.ProfileFailed) } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt index 1dc7c14288..bd17a9293b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/home/component/SettingProfile.kt @@ -10,20 +10,26 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R +import com.afternote.core.ui.R as CoreUiR @Composable internal fun SettingProfile( name: String, email: String, + profileImageUrl: String?, onInquiryClick: () -> Unit, onNoticeClick: () -> Unit, onRecipientListClick: () -> Unit, @@ -43,11 +49,24 @@ internal fun SettingProfile( .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { - Image( - painterResource(R.drawable.setting_ic_default_profile), - contentDescription = "기본 이미지", - modifier = Modifier.size(60.dp), - ) + val defaultProfile = painterResource(R.drawable.setting_ic_default_profile) + val profileDescription = stringResource(CoreUiR.string.core_ui_content_description_profile_image) + if (profileImageUrl.isNullOrBlank()) { + Image( + painter = defaultProfile, + contentDescription = profileDescription, + modifier = Modifier.size(60.dp).clip(CircleShape), + ) + } else { + AsyncImage( + model = profileImageUrl, + contentDescription = profileDescription, + modifier = Modifier.size(60.dp).clip(CircleShape), + placeholder = defaultProfile, + error = defaultProfile, + contentScale = ContentScale.Crop, + ) + } Spacer(modifier = Modifier.padding(12.dp)) Column { Text( diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt new file mode 100644 index 0000000000..128c9f628c --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileAvatarTest.kt @@ -0,0 +1,161 @@ +package com.afternote.feature.setting.presentation.home + +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.view.View +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import coil3.asImage +import coil3.intercept.Interceptor +import coil3.request.ErrorResult +import coil3.request.SuccessResult +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.home.component.SettingProfile +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.IOException +import java.util.Collections +import kotlin.math.sqrt +import com.afternote.core.ui.R as CoreUiR + +@OptIn(DelicateCoilApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], application = Application::class) +class SettingProfileAvatarTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var imageLoader: ImageLoader + private lateinit var hostView: View + private var imageUrl by mutableStateOf(null) + private val requests = Collections.synchronizedList(mutableListOf()) + private val context get() = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + imageLoader = + ImageLoader + .Builder(context) + .components { + add( + Interceptor { chain -> + requests += chain.request.data + if (chain.request.data == BROKEN) { + ErrorResult(null, chain.request, IOException("missing image")) + } else { + val bitmap = Bitmap.createBitmap(60, 60, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(if (chain.request.data == FIRST) Color.RED else Color.GREEN) + SuccessResult(bitmap.asImage(), chain.request) + } + }, + ) + }.build() + SingletonImageLoader.setUnsafe(imageLoader) + composeRule.setContent { + hostView = LocalView.current + AfternoteTheme { + SettingProfile( + name = "name", + email = "user@example.com", + profileImageUrl = imageUrl, + onInquiryClick = {}, + onNoticeClick = {}, + onRecipientListClick = {}, + ) + } + } + } + + @After + fun tearDown() { + SingletonImageLoader.reset() + imageLoader.shutdown() + } + + @Test + fun `등록 사진을 그리고 다른 URL로 바뀌면 새 사진을 그린다`() { + show(FIRST) + waitForColor(Color.RED) + show(SECOND) + waitForColor(Color.GREEN) + assertEquals(listOf(FIRST, SECOND), requests) + } + + @Test + fun `사진 삭제와 빈 URL은 기본 이미지로 돌아가고 새 이미지 요청을 하지 않는다`() { + val defaultPixels = avatarPixels() + show(FIRST) + waitForColor(Color.RED) + + show(null) + assertArrayEquals(defaultPixels, avatarPixels()) + show(" ") + assertArrayEquals(defaultPixels, avatarPixels()) + assertEquals(listOf(FIRST), requests) + } + + @Test + fun `새 사진 로드 실패는 이전 사진 대신 기본 이미지를 그린다`() { + val defaultPixels = avatarPixels() + show(FIRST) + waitForColor(Color.RED) + + show(BROKEN) + composeRule.waitUntil(5_000) { requests.contains(BROKEN) } + composeRule.waitUntil(5_000) { avatarPixels().contentEquals(defaultPixels) } + assertArrayEquals(defaultPixels, avatarPixels()) + } + + private fun show(url: String?) { + composeRule.runOnIdle { imageUrl = url } + composeRule.waitForIdle() + } + + private fun waitForColor(color: Int) { + composeRule.waitUntil(5_000) { + val pixels = avatarPixels() + val width = sqrt(pixels.size.toDouble()).toInt() + pixels[pixels.size / 2 + width / 2] == color + } + } + + private fun avatarPixels(): IntArray { + val description = context.getString(CoreUiR.string.core_ui_content_description_profile_image) + val bounds = composeRule.onNodeWithContentDescription(description).fetchSemanticsNode().boundsInRoot + return composeRule.runOnIdle { + val bitmap = Bitmap.createBitmap(hostView.width, hostView.height, Bitmap.Config.ARGB_8888) + hostView.draw(Canvas(bitmap)) + val width = bounds.width.toInt() + val height = bounds.height.toInt() + IntArray(width * height).also { + bitmap.getPixels(it, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height) + } + } + } + + private companion object { + const val FIRST = "https://images.example/first.jpg" + const val SECOND = "https://images.example/second.jpg" + const val BROKEN = "https://images.example/missing.jpg" + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt new file mode 100644 index 0000000000..ec886de0fc --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileImageTest.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.setting.presentation.home + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.model.user.User +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingProfileImageTest { + private val dispatcher = StandardTestDispatcher() + private val repository = FakeMyProfileRepository.strict() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `프로필 조회와 갱신 결과의 사진 URL을 홈 상태에 전달한다`() = + runTest(dispatcher) { + var imageUrl = "https://images.example/first.jpg" + repository.onGetMyProfile = { User("name", "user@example.com", null, imageUrl) } + val viewModel = viewModel() + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + + imageUrl = "https://images.example/second.jpg" + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + } + + @Test + fun `갱신 결과에 사진이 없으면 이전 URL을 남기지 않는다`() = + runTest(dispatcher) { + var imageUrl: String? = "https://images.example/first.jpg" + repository.onGetMyProfile = { User("name", "user@example.com", null, imageUrl) } + val viewModel = viewModel() + runCurrent() + assertEquals(imageUrl, viewModel.profile().profileImageUrl) + + imageUrl = null + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertNull(viewModel.profile().profileImageUrl) + } + + @Test + fun `프로필 갱신 실패 뒤에는 이전 사진을 담은 성공 상태가 남지 않는다`() = + runTest(dispatcher) { + repository.onGetMyProfile = { User("name", "user@example.com", null, "https://images.example/first.jpg") } + val viewModel = viewModel() + runCurrent() + + repository.onGetMyProfile = { throw IOException("offline") } + viewModel.onIntent(SettingIntent.Refresh) + runCurrent() + assertEquals(SettingProfileState.Error, viewModel.uiState.value.profile) + } + + private fun viewModel() = + SettingViewModel( + authRepository = FakeAuthRepository.strict(), + myProfileRepository = repository, + accountRepository = FakeSettingAccountRepository.strict(), + ) + + private fun SettingViewModel.profile() = uiState.value.profile as SettingProfileState.Success +} From 84a679972c7c5a3febdae1a42a613c2ed8f5a693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 21:06:35 +0900 Subject: [PATCH 054/116] fix: refresh setting screens on return without losing edits (#1920) --- .../account/ConnectedAccountsContract.kt | 2 + .../account/ConnectedAccountsScreen.kt | 3 + .../account/ConnectedAccountsViewModel.kt | 25 +- .../delivery/DeliveryConditionContract.kt | 3 + .../delivery/DeliveryConditionScreen.kt | 3 + .../delivery/DeliveryConditionUiState.kt | 2 + .../delivery/DeliveryConditionViewModel.kt | 64 +- .../notification/NotificationSettingScreen.kt | 2 +- .../notification/PushNotificationContract.kt | 2 + .../notification/PushNotificationScreen.kt | 3 + .../notification/PushNotificationUiState.kt | 1 + .../notification/PushNotificationViewModel.kt | 85 ++- .../profile/ProfileEditContract.kt | 2 + .../presentation/profile/ProfileEditScreen.kt | 19 + .../profile/ProfileEditViewModel.kt | 21 +- .../presentation/ProfileReentryFormTest.kt | 93 +++ .../SettingReentryViewModelTest.kt | 611 ++++++++++++++++++ .../NotificationSettingSignalTest.kt | 19 + .../RefreshOnReturnWiringKonsistTest.kt | 19 +- 19 files changed, 930 insertions(+), 49 deletions(-) create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt create mode 100644 feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt index cb5c3ae9af..53c4241d99 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsContract.kt @@ -4,6 +4,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ConnectedAccountsIntent : MviIntent { + data object RefreshOnReturn : ConnectedAccountsIntent + data object RetryLoad : ConnectedAccountsIntent data class ConsumeError( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt index 9decf5f17f..35192d1bf1 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.credentials.CredentialManager import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.ui.findActivity @@ -32,6 +34,7 @@ internal fun ConnectedAccountsScreen( viewModel: ConnectedAccountsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) } val context = LocalContext.current val credentialManager = remember(context) { CredentialManager.create(context) } val snackbarHostState = remember { SnackbarHostState() } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt index 045b26bde4..a076487de2 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/account/ConnectedAccountsViewModel.kt @@ -20,6 +20,7 @@ internal class ConnectedAccountsViewModel ConnectedAccountsUiState(isLoading = true), ) { private var loadJob: Job? = null + private var isFirstResume = true init { loadConnectedAccounts() @@ -27,6 +28,10 @@ internal class ConnectedAccountsViewModel override fun onIntent(intent: ConnectedAccountsIntent) { when (intent) { + ConnectedAccountsIntent.RefreshOnReturn -> { + refreshOnReturn() + } + ConnectedAccountsIntent.RetryLoad -> { if (currentState.errorMessage != null) loadConnectedAccounts() } @@ -100,16 +105,26 @@ internal class ConnectedAccountsViewModel } } - private fun loadConnectedAccounts() { - if (loadJob?.isActive == true) return - dispatch(ConnectedAccountsReducerEvent.Loading) + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return + } + loadConnectedAccounts(isAutomatic = true) + } + + private fun loadConnectedAccounts(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || currentState.isUpdating || currentState.pendingLinkProvider != null) return + if (!isAutomatic) dispatch(ConnectedAccountsReducerEvent.Loading) loadJob = viewModelScope.launch { runCatchingCancellable { accountRepository.getConnectedAccounts() } .onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList())) }.onFailure { - dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + if (!isAutomatic || currentState.accounts.isEmpty()) { + dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다.")) + } } } } @@ -131,6 +146,7 @@ internal class ConnectedAccountsViewModel accessToken: String, ) { if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating) return + loadJob?.cancel() dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) } @@ -140,6 +156,7 @@ internal class ConnectedAccountsViewModel } private fun unlink(provider: String) { + loadJob?.cancel() dispatch(ConnectedAccountsReducerEvent.Changing) viewModelScope.launch { runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt index 53e1170cda..86ac13d4fe 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt @@ -6,6 +6,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface DeliveryConditionIntent : MviIntent { + data object RefreshOnReturn : DeliveryConditionIntent + data class SelectConditionType( val index: Int, ) : DeliveryConditionIntent @@ -31,6 +33,7 @@ internal sealed interface DeliveryConditionReducerEvent : ReducerEvent { data object Saving : DeliveryConditionReducerEvent data class Saved( + val revision: Int, val conditions: List, ) : DeliveryConditionReducerEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 8e3595cd72..d72d655e1f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.ui.mvi.ObserveFlag @@ -41,6 +43,7 @@ internal fun DeliveryConditionScreen( viewModel: DeliveryConditionViewModel, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) } ObserveFlag( raised = uiState.isSaved, consumed = DeliveryConditionIntent.ConsumeSuccess, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index 7ba60d8f0e..7f8a60700d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -7,6 +7,8 @@ import com.afternote.core.model.delivery.InactivityPeriod import com.afternote.core.ui.mvi.UiState internal data class DeliveryConditionUiState( + val conditionEditRevision: Int = 0, + val savedConditionRevision: Int = 0, val isLoading: Boolean = false, val isInitialized: Boolean = false, val conditionType: DeliveryConditionType = DeliveryConditionType.INACTIVITY, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 86b66603fc..b71a1d954d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -13,6 +13,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** @@ -28,9 +29,12 @@ internal class DeliveryConditionViewModel private val updateTimeLetterDeliveryCondition: UpdateTimeLetterDeliveryConditionUseCase, ) : MviViewModel(DeliveryConditionUiState()) { private val receiverId = route.receiverId + private var loadJob: Job? = null + private var isFirstResume = true override fun onIntent(intent: DeliveryConditionIntent) { when (intent) { + DeliveryConditionIntent.RefreshOnReturn -> refreshOnReturn() is DeliveryConditionIntent.SelectConditionType -> onConditionTypeSelected(intent.index) DeliveryConditionIntent.Save -> onSave() DeliveryConditionIntent.ConsumeSuccess -> dispatch(DeliveryConditionReducerEvent.SuccessConsumed) @@ -48,12 +52,26 @@ internal class DeliveryConditionViewModel is DeliveryConditionReducerEvent.Loaded -> { val representative = event.conditions.firstOrNull { it.contentType == DeliveryContentType.TIME_LETTER } + val hasEdits = state.conditionEditRevision != state.savedConditionRevision state.copy( isLoading = false, isInitialized = true, - conditionType = representative?.conditionType ?: DeliveryConditionType.INACTIVITY, - inactivityPeriod = representative?.inactivityPeriod ?: InactivityPeriod.ONE_YEAR, + conditionType = + if (hasEdits) { + state.conditionType + } else { + representative?.conditionType + ?: DeliveryConditionType.INACTIVITY + }, + inactivityPeriod = + if (hasEdits) { + state.inactivityPeriod + } else { + representative?.inactivityPeriod + ?: InactivityPeriod.ONE_YEAR + }, conditions = event.conditions, + error = state.error.takeUnless { it == DeliveryConditionError.LOAD_FAILED }, ) } @@ -62,7 +80,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.ConditionSelected -> { - state.copy(conditionType = event.type) + state.copy(conditionType = event.type, conditionEditRevision = state.conditionEditRevision + 1) } DeliveryConditionReducerEvent.Saving -> { @@ -70,7 +88,13 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true) + state.copy( + isSaving = false, + conditions = event.conditions, + isSaved = true, + savedConditionRevision = event.revision, + error = null, + ) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -86,16 +110,26 @@ internal class DeliveryConditionViewModel loadDeliveryConditions() } - private fun loadDeliveryConditions() { - viewModelScope.launch { - dispatch(DeliveryConditionReducerEvent.Loading) - runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } - .onSuccess { response -> - dispatch(DeliveryConditionReducerEvent.Loaded(response.conditions)) - }.onFailure { - dispatch(DeliveryConditionReducerEvent.LoadFailed) - } + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return } + loadDeliveryConditions(isAutomatic = true) + } + + private fun loadDeliveryConditions(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || currentState.isSaving) return + loadJob = + viewModelScope.launch { + if (!isAutomatic) dispatch(DeliveryConditionReducerEvent.Loading) + runCatchingCancellable { receiverRepository.getReceiverDeliveryConditions(receiverId) } + .onSuccess { response -> + dispatch(DeliveryConditionReducerEvent.Loaded(response.conditions)) + }.onFailure { + if (!isAutomatic || !currentState.isInitialized) dispatch(DeliveryConditionReducerEvent.LoadFailed) + } + } } private fun onConditionTypeSelected(index: Int) { @@ -108,6 +142,8 @@ internal class DeliveryConditionViewModel val state = currentState if (!state.isInitialized || state.isSaving || state.isSaved) return + loadJob?.cancel() + val savingRevision = state.conditionEditRevision dispatch(DeliveryConditionReducerEvent.Saving) viewModelScope.launch { runCatchingCancellable { @@ -118,7 +154,7 @@ internal class DeliveryConditionViewModel inactivityPeriod = state.inactivityPeriod, ) }.onSuccess { response -> - dispatch(DeliveryConditionReducerEvent.Saved(response.conditions)) + dispatch(DeliveryConditionReducerEvent.Saved(savingRevision, response.conditions)) }.onFailure { dispatch(DeliveryConditionReducerEvent.SaveFailed) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt index dfe059cdfa..9487ad1c42 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingScreen.kt @@ -82,7 +82,7 @@ internal fun NotificationSettingScreen( } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.onIntent(PushNotificationIntent.RefreshDeviceAlarmStatus) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) } // 소비가 ObserveFlag 의 effect 를 다시 시작시켜도 스낵바가 끊기지 않게 화면 코루틴에 띄운다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt index 3ad1bf42a0..b3b35773fd 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationContract.kt @@ -6,6 +6,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface PushNotificationIntent : MviIntent { + data object RefreshOnReturn : PushNotificationIntent + data object RetryLoad : PushNotificationIntent /** 기기 알림 허용 여부를 다시 읽는다. 화면 재개와 권한 요청 결과 때 보낸다. */ diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt index a0e68dfe4e..87333a6a64 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationScreen.kt @@ -3,6 +3,8 @@ package com.afternote.feature.setting.presentation.notification import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.popup.NetworkErrorPopup import com.afternote.core.ui.popup.ServerErrorPopup @@ -13,6 +15,7 @@ internal fun PushNotificationScreen( viewModel: PushNotificationViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) } PushNotificationContent( uiState = uiState, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt index 71c9a1f277..2f42a72486 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationUiState.kt @@ -5,6 +5,7 @@ import com.afternote.core.ui.mvi.UiState internal data class PushNotificationUiState( val isLoading: Boolean = false, + val hasLoadedPushSettings: Boolean = false, val errorMessage: UiText? = null, val isDeviceAlarmOn: Boolean = false, // 마케팅 알림 (기기 알림 꺼졌을 때) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt index de78ce49bf..1364a0a6ac 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/notification/PushNotificationViewModel.kt @@ -28,6 +28,9 @@ internal class PushNotificationViewModel PushNotificationUiState(isLoading = true), ) { private var loadJob: Job? = null + private var marketingLoadJob: Job? = null + private var marketingWritesInFlight = 0 + private var isFirstResume = true init { refreshDeviceAlarmStatus() @@ -37,6 +40,10 @@ internal class PushNotificationViewModel override fun onIntent(intent: PushNotificationIntent) { when (intent) { + PushNotificationIntent.RefreshOnReturn -> { + refreshOnReturn() + } + PushNotificationIntent.RetryLoad -> { if (currentState.errorMessage != null) loadPushSettings() } @@ -92,6 +99,7 @@ internal class PushNotificationViewModel state.copy( isLoading = false, errorMessage = null, + hasLoadedPushSettings = true, isNewsletterOn = event.setting.timeLetter, isMindRecordOn = event.setting.mindRecord, isAfternoteOn = event.setting.afterNote, @@ -159,9 +167,23 @@ internal class PushNotificationViewModel dispatch(PushNotificationReducerEvent.DeviceAlarmStatusRead(deviceAlarmOn)) } - private fun loadPushSettings() { - if (loadJob?.isActive == true) return - dispatch(PushNotificationReducerEvent.PushSettingsLoading) + private fun refreshOnReturn() { + refreshDeviceAlarmStatus() + if (isFirstResume) { + isFirstResume = false + return + } + loadPushSettings(isAutomatic = true) + loadMarketingConsents() + } + + private fun loadPushSettings(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || + currentState.run { isNewsletterUpdating || isMindRecordUpdating || isAfternoteUpdating } + ) { + return + } + if (!isAutomatic) dispatch(PushNotificationReducerEvent.PushSettingsLoading) loadJob = viewModelScope.launch { Log.d(TAG, "loadPushSettings: start") @@ -172,41 +194,53 @@ internal class PushNotificationViewModel }.onFailure { e -> // 조회 실패는 Logcat 에만 남긴다. 반복 조회 잡음이 저장 실패 진단의 보관 한도를 밀어내지 않게 한다 (#963). Log.e(TAG, "loadPushSettings: failed", e) - dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + if (!isAutomatic || + !currentState.hasLoadedPushSettings + ) { + dispatch(PushNotificationReducerEvent.PushSettingsLoadFailed) + } } } } private fun loadMarketingConsents() { - viewModelScope.launch { - Log.d(TAG, "loadMarketingConsents: start") - runCatchingCancellable { notificationRepository.getMyMarketingConsents() } - .onSuccess { consent -> - Log.d(TAG, "loadMarketingConsents: success=$consent") - dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) - }.onFailure { e -> - Log.e(TAG, "loadMarketingConsents: failed", e) - } - } + if (marketingLoadJob?.isActive == true || marketingWritesInFlight > 0) return + marketingLoadJob = + viewModelScope.launch { + Log.d(TAG, "loadMarketingConsents: start") + runCatchingCancellable { notificationRepository.getMyMarketingConsents() } + .onSuccess { consent -> + Log.d(TAG, "loadMarketingConsents: success=$consent") + dispatch(PushNotificationReducerEvent.MarketingConsentsLoaded(consent)) + }.onFailure { e -> + Log.e(TAG, "loadMarketingConsents: failed", e) + } + } } private fun updateMarketingConsent( consent: MarketingConsent, checked: Boolean, ) { + marketingLoadJob?.cancel() + marketingWritesInFlight++ dispatch(PushNotificationReducerEvent.MarketingConsentChanged(consent, checked)) viewModelScope.launch { - runCatchingCancellable { - notificationRepository.updateMyMarketingConsents( - sms = checked.takeIf { consent == MarketingConsent.SMS }, - email = checked.takeIf { consent == MarketingConsent.EMAIL }, - push = checked.takeIf { consent == MarketingConsent.PUSH }, - ) - }.onSuccess { - Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") - }.onFailure { e -> - errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) - dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + try { + runCatchingCancellable { + notificationRepository.updateMyMarketingConsents( + sms = checked.takeIf { consent == MarketingConsent.SMS }, + email = checked.takeIf { consent == MarketingConsent.EMAIL }, + push = checked.takeIf { consent == MarketingConsent.PUSH }, + ) + }.onSuccess { + Log.d(TAG, "updateMarketingConsent: success, consent=$consent, checked=$checked") + }.onFailure { e -> + errorReporter.recordFailure(e, mapOf(KEY_STAGE to consent.reportingStage())) + dispatch(PushNotificationReducerEvent.MarketingConsentSaveFailed(consent, requested = checked)) + } + } finally { + marketingWritesInFlight-- } } } @@ -219,6 +253,7 @@ internal class PushNotificationViewModel private fun updatePushSetting(update: PushSettingUpdate) { if (currentState.isLoading || currentState.errorMessage != null || currentState.isUpdating(update.setting)) return + loadJob?.cancel() val previousValue = currentState.valueOf(update.setting) dispatch(PushNotificationReducerEvent.PushSettingSaving(update)) viewModelScope.launch { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt index bdb8ee77cf..597b2b1813 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditContract.kt @@ -4,6 +4,8 @@ import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface ProfileEditIntent : MviIntent { + data object RefreshOnReturn : ProfileEditIntent + data object RetryLoad : ProfileEditIntent data class SelectPhoto( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 9907ed70ac..a969ebad2a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold @@ -30,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,6 +39,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.ProfileImagePicker @@ -59,6 +63,7 @@ internal fun ProfileEditScreen( viewModel: ProfileEditViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) } val snackbarHostState = remember { SnackbarHostState() } val feedbackScope = rememberCoroutineScope() val updateFailedMessage = stringResource(R.string.setting_profile_update_error) @@ -164,6 +169,20 @@ private fun ProfileEditForm( val nameState = rememberTextFieldState(initialText = state.name) val phoneState = rememberTextFieldState(initialText = state.phone) val emailState = rememberTextFieldState(initialText = state.email) + var previousName by rememberSaveable { mutableStateOf(state.name) } + var previousPhone by rememberSaveable { mutableStateOf(state.phone) } + LaunchedEffect(state.name, state.phone, state.email) { + // 편집하지 않은 필드만 새 서버 값으로 바꾸고 입력 중인 값과 커서를 보존한다. + if (nameState.text.toString() == previousName && previousName != state.name) { + nameState.setTextAndPlaceCursorAtEnd(state.name) + } + if (phoneState.text.toString() == previousPhone && previousPhone != state.phone) { + phoneState.setTextAndPlaceCursorAtEnd(state.phone) + } + if (emailState.text.toString() != state.email) emailState.setTextAndPlaceCursorAtEnd(state.email) + previousName = state.name + previousPhone = state.phone + } val isPhoneInvalid = phoneState.text.toString().validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID LazyColumn( diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index 782c1d5ffd..39be8a16c0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -22,6 +22,7 @@ internal class ProfileEditViewModel private val photoUploadRepository: PhotoUploadRepository, ) : MviViewModel(ProfileEditUiState.Loading) { private var loadJob: Job? = null + private var isFirstResume = true init { loadProfile() @@ -29,6 +30,7 @@ internal class ProfileEditViewModel override fun onIntent(intent: ProfileEditIntent) { when (intent) { + ProfileEditIntent.RefreshOnReturn -> refreshOnReturn() ProfileEditIntent.RetryLoad -> if (currentState == ProfileEditUiState.Error) loadProfile() is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri)) is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone) @@ -51,6 +53,8 @@ internal class ProfileEditViewModel phone = event.phone, email = event.email, profileImageUrl = event.profileImageUrl, + selectedImageUri = (state as? ProfileEditUiState.Success)?.selectedImageUri, + pendingEvent = (state as? ProfileEditUiState.Success)?.pendingEvent, ) } @@ -80,9 +84,17 @@ internal class ProfileEditViewModel } } - private fun loadProfile() { - if (loadJob?.isActive == true) return - dispatch(ProfileEditReducerEvent.Loading) + private fun refreshOnReturn() { + if (isFirstResume) { + isFirstResume = false + return + } + loadProfile(isAutomatic = true) + } + + private fun loadProfile(isAutomatic: Boolean = false) { + if (loadJob?.isActive == true || (currentState as? ProfileEditUiState.Success)?.isUpdating == true) return + if (!isAutomatic) dispatch(ProfileEditReducerEvent.Loading) loadJob = viewModelScope.launch { runCatchingCancellable { myProfileRepository.getMyProfile() } @@ -96,7 +108,7 @@ internal class ProfileEditViewModel ), ) }.onFailure { - dispatch(ProfileEditReducerEvent.LoadFailed) + if (!isAutomatic || currentState !is ProfileEditUiState.Success) dispatch(ProfileEditReducerEvent.LoadFailed) } } } @@ -108,6 +120,7 @@ internal class ProfileEditViewModel val current = currentState as? ProfileEditUiState.Success ?: return if (current.isUpdating) return if (phone.validateReceiverPhone(isRequired = false) != ReceiverPhoneValidation.VALID) return + loadJob?.cancel() dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt new file mode 100644 index 0000000000..ecc217b4a3 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ProfileReentryFormTest.kt @@ -0,0 +1,93 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent +import com.afternote.feature.setting.presentation.profile.ProfileEditScreen +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ProfileReentryFormTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun successfulRefresh_preservesEditedNameAndUpdatesUntouchedFields() { + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { User("초기 이름", "before@example.com", "01011112222", null) } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + setContent(viewModel) + composeRule.onNode(hasSetTextAction() and hasText("초기 이름")).performScrollTo().performTextReplacement("작성 중인 이름") + + composeRule.runOnIdle { + repository.onGetMyProfile = { User("서버 이름", "after@example.com", "01033334444", null) } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + } + composeRule.waitUntil(timeoutMillis = 5_000) { + (viewModel.uiState.value as? ProfileEditUiState.Success)?.name == "서버 이름" + } + + composeRule.onNodeWithText("작성 중인 이름").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("01033334444").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("after@example.com").performScrollTo().assertIsDisplayed() + } + + @Test + fun failedRefresh_keepsEditedFormMounted() { + var calls = 0 + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + calls++ + User("초기 이름", "before@example.com", "01011112222", null) + } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + setContent(viewModel) + composeRule.onNode(hasSetTextAction() and hasText("초기 이름")).performScrollTo().performTextReplacement("작성 중인 이름") + composeRule.onNode(hasSetTextAction() and hasText("01011112222")).performScrollTo().performTextReplacement("01099998888") + + composeRule.runOnIdle { + repository.onGetMyProfile = { + calls++ + error("offline") + } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + } + composeRule.waitUntil(timeoutMillis = 5_000) { calls == 2 } + + assertTrue(viewModel.uiState.value is ProfileEditUiState.Success) + composeRule.onNodeWithText("작성 중인 이름").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("01099998888").performScrollTo().assertIsDisplayed() + } + + private fun setContent(viewModel: ProfileEditViewModel) { + composeRule.setContent { + AfternoteTheme { + ProfileEditScreen(onBackClick = {}, onWithdrawGuideClick = {}, viewModel = viewModel) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value is ProfileEditUiState.Success } + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt new file mode 100644 index 0000000000..27013bf8bf --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingReentryViewModelTest.kt @@ -0,0 +1,611 @@ +package com.afternote.feature.setting.presentation + +import androidx.test.core.app.ApplicationProvider +import com.afternote.core.domain.testing.FakeMyProfileRepository +import com.afternote.core.domain.testing.FakePhotoUploadRepository +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 com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.User +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.model.user.UserMarketingConsent +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.setting.domain.UpdateTimeLetterDeliveryConditionUseCase +import com.afternote.feature.setting.domain.testing.FakeSettingAccountRepository +import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository +import com.afternote.feature.setting.presentation.NoOpErrorReporter +import com.afternote.feature.setting.presentation.account.ConnectedAccountsIntent +import com.afternote.feature.setting.presentation.account.ConnectedAccountsViewModel +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionError +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionIntent +import com.afternote.feature.setting.presentation.delivery.DeliveryConditionViewModel +import com.afternote.feature.setting.presentation.navigation.SettingRoute +import com.afternote.feature.setting.presentation.notification.MarketingConsent +import com.afternote.feature.setting.presentation.notification.PushNotificationIntent +import com.afternote.feature.setting.presentation.notification.PushNotificationViewModel +import com.afternote.feature.setting.presentation.notification.PushSetting +import com.afternote.feature.setting.presentation.profile.ProfileEditIntent +import com.afternote.feature.setting.presentation.profile.ProfileEditUiState +import com.afternote.feature.setting.presentation.profile.ProfileEditViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SettingReentryViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun connectedAccounts_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { + calls++ + response.await() + } + } + val viewModel = ConnectedAccountsViewModel(repository) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(accounts()) + runCurrent() + val previous = viewModel.uiState.value + + response = CompletableDeferred() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(accounts(google = true)) + runCurrent() + assertTrue( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun connectedAccounts_failedRefreshPreservesLoadedState() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository.strict().apply { onGetConnectedAccounts = { accounts() } } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetConnectedAccounts = { error("offline") } + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun connectedAccounts_successfulReentryClearsInitialLoadError() = + runTest(dispatcher) { + val repository = FakeSettingAccountRepository.strict().apply { onGetConnectedAccounts = { error("offline") } } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + assertNotNull(viewModel.uiState.value.errorMessage) + repository.onGetConnectedAccounts = { accounts() } + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertNull(viewModel.uiState.value.errorMessage) + assertEquals(4, viewModel.uiState.value.accounts.size) + } + + @Test + fun connectedAccounts_linkCancelsStaleReadAndResumeWaitsForMutation() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val linked = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { if (++reads == 1) accounts() else stale.await() } + onLinkConnectedAccount = { _, _ -> linked.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.Link("google", "token")) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + linked.complete(accounts(google = true)) + runCurrent() + stale.complete(accounts()) + runCurrent() + assertTrue( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun connectedAccounts_unlinkCancelsStaleReadAndResumeWaitsForMutation() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val unlinked = CompletableDeferred() + val repository = + FakeSettingAccountRepository.strict().apply { + onGetConnectedAccounts = { if (++reads == 1) accounts(google = true) else stale.await() } + onUnlinkConnectedAccount = { unlinked.await() } + } + val viewModel = ConnectedAccountsViewModel(repository) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ConnectedAccountsIntent.Toggle("google", false)) + viewModel.onIntent(ConnectedAccountsIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + unlinked.complete(accounts()) + runCurrent() + stale.complete(accounts(google = true)) + runCurrent() + assertFalse( + viewModel.uiState.value.accounts + .single { it.provider == "google" } + .isConnected, + ) + } + + @Test + fun delivery_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { + calls++ + response.await() + } + } + val viewModel = deliveryViewModel(repository) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(delivery()) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(delivery(DeliveryConditionType.RECEIVER_REQUEST)) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun delivery_refreshPreservesUnsavedSelectionAndSavesItWithRefreshedConditions() = + runTest(dispatcher) { + val response = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { delivery() } + onUpdateReceiverDeliveryConditions = { id, conditions -> ReceiverDeliveryConditions(id, conditions) } + } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + repository.onGetReceiverDeliveryConditions = { response.await() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + val refreshed = + delivery().let { + it.copy( + conditions = + it.conditions + it.conditions.single().copy(contentType = DeliveryContentType.DIARY), + ) + } + response.complete(refreshed) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + viewModel.onIntent(DeliveryConditionIntent.Save) + runCurrent() + val saved = repository.deliveryUpdateCalls.single().conditions + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, saved.first().conditionType) + assertNull(saved.first().inactivityPeriod) + assertEquals(refreshed.conditions.last(), saved.last()) + } + + @Test + fun delivery_editWhileRefreshIsPendingIsNotOverwritten() = + runTest(dispatcher) { + val response = CompletableDeferred() + val repository = FakeUserReceiverRepository.strict().apply { onGetReceiverDeliveryConditions = { delivery() } } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + repository.onGetReceiverDeliveryConditions = { response.await() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + response.complete(delivery()) + runCurrent() + assertEquals(DeliveryConditionType.RECEIVER_REQUEST, viewModel.uiState.value.conditionType) + } + + @Test + fun delivery_failedRefreshPreservesLoadedStateAndSuccessfulRetryClearsInitialError() = + runTest(dispatcher) { + val repository = FakeUserReceiverRepository.strict().apply { onGetReceiverDeliveryConditions = { error("offline") } } + val viewModel = deliveryViewModel(repository) + runCurrent() + assertEquals(DeliveryConditionError.LOAD_FAILED, viewModel.uiState.value.error) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + repository.onGetReceiverDeliveryConditions = { delivery() } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertNull(viewModel.uiState.value.error) + val previous = viewModel.uiState.value + repository.onGetReceiverDeliveryConditions = { error("offline") } + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun delivery_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeUserReceiverRepository.strict().apply { + onGetReceiverDeliveryConditions = { if (++reads == 1) delivery() else stale.await() } + onUpdateReceiverDeliveryConditions = { _, _ -> saved.await() } + } + val viewModel = deliveryViewModel(repository) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(1)) + viewModel.onIntent(DeliveryConditionIntent.Save) + viewModel.onIntent(DeliveryConditionIntent.Save) + viewModel.onIntent(DeliveryConditionIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, repository.deliveryUpdateCalls.size) + assertEquals(2, reads) + saved.complete(delivery(DeliveryConditionType.RECEIVER_REQUEST)) + runCurrent() + stale.complete(delivery()) + runCurrent() + assertEquals( + DeliveryConditionType.RECEIVER_REQUEST, + viewModel.uiState.value.conditions + .single() + .conditionType, + ) + } + + @Test + fun profile_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { + calls++ + response.await() + } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(user()) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(user().copy(name = "새 이름")) + runCurrent() + assertEquals("새 이름", (viewModel.uiState.value as ProfileEditUiState.Success).name) + } + + @Test + fun profile_failedRefreshPreservesLoadedForm() = + runTest(dispatcher) { + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { user() } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetMyProfile = { error("offline") } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun profile_successfulReentryRecoversInitialError() = + runTest(dispatcher) { + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { error("offline") } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + assertEquals(ProfileEditUiState.Error, viewModel.uiState.value) + repository.onGetMyProfile = { user() } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertTrue(viewModel.uiState.value is ProfileEditUiState.Success) + } + + @Test + fun profile_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeMyProfileRepository.strict().apply { + onGetMyProfile = { if (++reads == 1) user() else stale.await() } + onUpdateMyProfile = { _, _, _ -> saved.await() } + } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ProfileEditIntent.UpdateProfile("작성한 이름", "01012345678")) + viewModel.onIntent(ProfileEditIntent.UpdateProfile("중복", "01000000000")) + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + assertEquals(1, repository.profileUpdateCalls.size) + saved.completeExceptionally(IllegalStateException("save failed")) + runCurrent() + stale.complete(user().copy(name = "stale")) + runCurrent() + assertEquals("이름", (viewModel.uiState.value as ProfileEditUiState.Success).name) + assertFalse((viewModel.uiState.value as ProfileEditUiState.Success).isUpdating) + } + + @Test + fun push_firstResumeAndPendingLoadDoNotDuplicateAndRefreshKeepsContent() = + runTest(dispatcher) { + var calls = 0 + var response = CompletableDeferred() + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { + calls++ + response.await() + } + } + val viewModel = pushViewModel(repository) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, calls) + response.complete(UserPushSetting(false, false, false)) + runCurrent() + val previous = viewModel.uiState.value + response = CompletableDeferred() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, calls) + assertEquals(previous, viewModel.uiState.value) + response.complete(UserPushSetting(true, true, true)) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + assertTrue(viewModel.uiState.value.isMindRecordOn) + assertTrue(viewModel.uiState.value.isAfternoteOn) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun push_failedRefreshPreservesLoadedState() = + runTest(dispatcher) { + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { + UserPushSetting( + true, + true, + true, + ) + } + } + val viewModel = pushViewModel(repository) + runCurrent() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + val previous = viewModel.uiState.value + repository.onGetMyPushSettings = { error("offline") } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun push_saveCancelsStaleReadAndResumeDoesNotReloadDuringSave() = + runTest(dispatcher) { + var reads = 0 + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = + FakeSettingNotificationRepository.strict().apply { + onGetMyPushSettings = { if (++reads == 1) UserPushSetting(false, false, false) else stale.await() } + onUpdateMyPushSettings = { _, _, _ -> saved.await() } + } + val viewModel = pushViewModel(repository) + runCurrent() + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(PushNotificationIntent.TogglePushSetting(PushSetting.NEWSLETTER, true)) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, reads) + saved.complete(UserPushSetting(true, false, false)) + runCurrent() + stale.complete(UserPushSetting(false, false, false)) + runCurrent() + assertTrue(viewModel.uiState.value.isNewsletterOn) + } + + @Test + fun profile_refreshPreservesPhotoSelectedWhileRequestIsPending() = + runTest(dispatcher) { + val pending = CompletableDeferred() + val repository = FakeMyProfileRepository.strict().apply { onGetMyProfile = { user() } } + val viewModel = ProfileEditViewModel(repository, FakePhotoUploadRepository.strict()) + runCurrent() + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + repository.onGetMyProfile = { pending.await() } + viewModel.onIntent(ProfileEditIntent.RefreshOnReturn) + runCurrent() + viewModel.onIntent(ProfileEditIntent.SelectPhoto("content://picked")) + pending.complete(user().copy(profileImageUrl = "https://example.com/server.jpg")) + runCurrent() + assertEquals("content://picked", (viewModel.uiState.value as ProfileEditUiState.Success).displayImageUri) + } + + @Test + fun marketing_firstResumeSkipsReadAndLaterResumeRefreshesServerConsent() = + runTest(dispatcher) { + val repository = FakeSettingNotificationRepository() + val viewModel = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(1, repository.getMyMarketingConsentsCalls) + repository.marketingConsent = UserMarketingConsent(false, false, true) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isSmsChecked) + assertFalse(viewModel.uiState.value.isEmailChecked) + assertTrue(viewModel.uiState.value.isPushChecked) + val previous = viewModel.uiState.value + repository.onGetMyMarketingConsents = { error("offline") } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(previous, viewModel.uiState.value) + } + + @Test + fun marketing_writeCancelsStaleLoadAndReentryWaitsForPendingWrite() = + runTest(dispatcher) { + val stale = CompletableDeferred() + val saved = CompletableDeferred() + val repository = FakeSettingNotificationRepository() + val viewModel = PushNotificationViewModel(ApplicationProvider.getApplicationContext(), repository, NoOpErrorReporter) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + repository.onGetMyMarketingConsents = { stale.await() } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + repository.onUpdateMyMarketingConsents = { _, _, _ -> saved.await() } + viewModel.onIntent(PushNotificationIntent.ChangeMarketingConsent(MarketingConsent.SMS, false)) + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(2, repository.getMyMarketingConsentsCalls) + stale.complete(UserMarketingConsent(true, true, false)) + saved.complete(UserMarketingConsent(false, true, false)) + runCurrent() + assertFalse(viewModel.uiState.value.isSmsChecked) + repository.onGetMyMarketingConsents = { UserMarketingConsent(false, false, true) } + viewModel.onIntent(PushNotificationIntent.RefreshOnReturn) + runCurrent() + assertEquals(3, repository.getMyMarketingConsentsCalls) + assertFalse(viewModel.uiState.value.isEmailChecked) + } + + private fun deliveryViewModel(repository: FakeUserReceiverRepository) = + DeliveryConditionViewModel( + SettingRoute.AfterDeliveryRoute(receiverId = 42L), + repository, + UpdateTimeLetterDeliveryConditionUseCase(repository), + ) + + private fun pushViewModel(repository: FakeSettingNotificationRepository) = + PushNotificationViewModel( + ApplicationProvider.getApplicationContext(), + repository.apply { onGetMyMarketingConsents = { UserMarketingConsent(true, true, false) } }, + NoOpErrorReporter, + ) + + private fun user() = User("이름", "user@example.com", "01012345678", null) + + private fun accounts(google: Boolean = false) = UserConnectedAccount(true, google, false, false, false, null, null, null, null, null) + + private fun delivery(type: DeliveryConditionType = DeliveryConditionType.INACTIVITY) = + ReceiverDeliveryConditions( + receiverId = 42L, + conditions = + listOf( + DeliveryConditionItem( + contentType = DeliveryContentType.TIME_LETTER, + conditionType = type, + inactivityPeriod = InactivityPeriod.SIX_MONTHS.takeIf { type == DeliveryConditionType.INACTIVITY }, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ), + ), + ) +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt index 4ef2fdb83c..69d87b2b6e 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/notification/NotificationSettingSignalTest.kt @@ -16,6 +16,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.setting.domain.testing.FakeSettingNotificationRepository import com.afternote.feature.setting.presentation.NoOpErrorReporter import com.afternote.feature.setting.presentation.R +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule @@ -114,6 +115,24 @@ class NotificationSettingSignalTest { assertFalse(isFailedMessageShown()) } + @Test + fun `알림 설정 화면 첫 진입은 중복 조회하지 않고 복귀하면 서버 동의를 갱신한다`() { + setScreen() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isMarketingFeedbackActive } + composeRule.waitForIdle() + assertEquals(1, repository.getMyMarketingConsentsCalls) + composeRule.runOnIdle { + lifecycleOwner.moveTo(Lifecycle.State.CREATED) + repository.onGetMyMarketingConsents = { UserMarketingConsent(sms = false, email = false, push = false) } + } + composeRule.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + repository.getMyMarketingConsentsCalls == 2 && !viewModel.uiState.value.isSmsChecked + } + assertFalse(viewModel.uiState.value.isEmailChecked) + assertFalse(viewModel.uiState.value.isPushChecked) + } + private fun isFailedMessageShown(): Boolean = composeRule.onAllNodesWithText(failedMessage).fetchSemanticsNodes().isNotEmpty() private fun setScreen() { diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt index a7d1feec94..ce9bd9bce8 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt @@ -35,7 +35,7 @@ class RefreshOnReturnWiringKonsistTest { @Test fun `refreshOnReturn 을 선언한 ViewModel 은 ON_RESUME 결선을 갖는다`() { val mainFiles = mainSourceFiles() - val wiringSites = mainFiles.filter { ON_RESUME in it.text && REFRESH_CALL in it.text } + val wiringSites = mainFiles.filter { ON_RESUME in it.text && (REFRESH_CALL in it.text || ".RefreshOnReturn" in it.text) } val unwired = mainFiles @@ -64,6 +64,23 @@ class RefreshOnReturnWiringKonsistTest { } } + @Test + fun `설정 재진입 갱신은 마케팅 동의를 포함한 모든 소비 화면에 결선된다`() { + val expected = + mapOf( + "ConnectedAccountsScreen.kt" to "ConnectedAccountsIntent", + "DeliveryConditionScreen.kt" to "DeliveryConditionIntent", + "ProfileEditScreen.kt" to "ProfileEditIntent", + "PushNotificationScreen.kt" to "PushNotificationIntent", + "NotificationSettingScreen.kt" to "PushNotificationIntent", + ) + val settingFiles = mainSourceFiles().filter { "/feature/setting/" in "/${it.normalizedProjectPath()}" } + expected.forEach { (fileName, intent) -> + val source = settingFiles.single { it.normalizedProjectPath().endsWith("/$fileName") }.text + check(ON_RESUME in source && "$intent.RefreshOnReturn" in source) { "$fileName 재진입 갱신 결선 누락" } + } + } + private fun mainSourceFiles(): List = AfternoteKonsistScope.files.filter { "/src/main/" in it.normalizedProjectPath() } From 4410eed6b15b5e3f6b3eac721c4e7c6357531d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 21:09:02 +0900 Subject: [PATCH 055/116] =?UTF-8?q?test:=20=EC=84=A4=EC=A0=95=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=95=84=EB=B0=94=ED=83=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=8A=A4=ED=81=AC=EB=A6=B0=EC=83=B7=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#2225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/SettingProfileScreenshotTest.kt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt new file mode 100644 index 0000000000..ea8aa9fc20 --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTest.kt @@ -0,0 +1,66 @@ +package com.afternote.feature.setting.presentation.home + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.tooling.preview.Preview +import coil3.annotation.ExperimentalCoilApi +import coil3.asImage +import coil3.compose.AsyncImagePainter +import coil3.compose.AsyncImagePreviewHandler +import coil3.compose.LocalAsyncImagePreviewHandler +import coil3.request.ErrorResult +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.home.component.SettingProfile +import com.android.tools.screenshot.PreviewTest +import java.io.IOException + +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfileDefaultScreenshot() { + SettingProfileScreenshotContent(profileImageUrl = null) +} + +@OptIn(ExperimentalCoilApi::class) +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfilePhotoScreenshot() { + val photo = + Bitmap + .createBitmap(160, 120, Bitmap.Config.ARGB_8888) + .apply { eraseColor(0xFF5B7DB1.toInt()) } + .asImage() + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides AsyncImagePreviewHandler { photo }) { + SettingProfileScreenshotContent(profileImageUrl = "content://preview/profile-photo") + } +} + +@OptIn(ExperimentalCoilApi::class) +@PreviewTest +@Preview(showBackground = true, widthDp = 360) +@Composable +internal fun settingProfilePhotoFailureScreenshot() { + val failedRequest = + AsyncImagePreviewHandler { _, request -> + AsyncImagePainter.State.Error(null, ErrorResult(null, request, IOException("missing image"))) + } + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides failedRequest) { + SettingProfileScreenshotContent(profileImageUrl = "content://preview/missing-photo") + } +} + +@Composable +private fun SettingProfileScreenshotContent(profileImageUrl: String?) { + AfternoteTheme { + SettingProfile( + name = "홍길동", + email = "user@example.com", + profileImageUrl = profileImageUrl, + onInquiryClick = {}, + onNoticeClick = {}, + onRecipientListClick = {}, + ) + } +} From c35314f959fbb50705db60fceac61f213e32a4b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:20:41 +0000 Subject: [PATCH 056/116] test: update screenshot baselines in CI --- ...ttingProfileDefaultScreenshot_74131fac_0.png | Bin 0 -> 20464 bytes ...ProfilePhotoFailureScreenshot_74131fac_0.png | Bin 0 -> 20464 bytes ...settingProfilePhotoScreenshot_74131fac_0.png | Bin 0 -> 19023 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoFailureScreenshot_74131fac_0.png create mode 100644 feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfilePhotoScreenshot_74131fac_0.png diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/home/SettingProfileScreenshotTestKt/settingProfileDefaultScreenshot_74131fac_0.png new file mode 100644 index 0000000000000000000000000000000000000000..a0942fdfd14be83aa44fb5a53e84afbd3d3cd286 GIT binary patch literal 20464 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 RJ!J^whMuu*rM6S_{{Ty#D$M`@ literal 0 HcmV?d00001 From 3ea21329be6a43754075c01bc5a7f01d333125dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 3 Oct 2026 19:52:19 +0900 Subject: [PATCH 057/116] =?UTF-8?q?fix(core):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9D=B4=20=EC=B7=A8=EC=86=8C=EB=8F=BC?= =?UTF-8?q?=EB=8F=84=20=EB=A1=9C=EC=BB=AC=20=EC=84=B8=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=81=9D=EB=82=B8=EB=8B=A4=20(#2243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthRepositoryImpl.logout 의 SESSION 스코프 정리와 선제 reissue deadline 정리를 finally 안 withContext(NonCancellable) 로 옮겨, 서버 응답이나 푸시 대상 해제를 기다리는 중 호출자가 취소돼도 로컬 세션이 남지 않게 한다. 정리 뒤 취소는 그대로 다시 던지고, 정상 경로의 API 호출 뒤 정리 순서는 유지한다. --- .../data/repoimpl/auth/AuthRepositoryImpl.kt | 34 ++++++---- .../repoimpl/auth/AuthRepositoryImplTest.kt | 64 ++++++++++++++++++- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index dbdf296cf9..39231ea8fc 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -18,7 +18,9 @@ import com.afternote.core.network.model.requireData import com.afternote.core.network.service.AuthApiService import com.afternote.core.network.service.TokenApiService import com.afternote.core.network.token.AccessTokenExpiryTracker +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject internal class AuthRepositoryImpl @@ -128,22 +130,32 @@ internal class AuthRepositoryImpl /** * 서버 로그아웃은 best-effort (네트워크 실패해도 사용자는 로그아웃 상태로 가야 함). * SESSION 스코프 로컬 저장소와 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. + * + * 호출자가 취소돼도 로컬 정리는 끝낸다 (#2243). 서버는 요청을 받은 순간 refresh 를 지우는데 + * 클라이언트의 취소는 서버에 전달되지 않으므로, 호출 화면이 응답 전에 사라졌다고 로컬 세션을 + * 남기면 앱만 로그인 상태로 남는다. 정리를 마친 뒤 취소는 그대로 다시 던진다. */ override suspend fun logout(): Result = runCatchingCancellable { - // 푸시 대상 해제가 먼저다 — 이 요청도 액세스 토큰을 달고 나가므로 세션이 살아 있어야 한다. - // 실패해도 로그아웃은 진행한다(best-effort). 남은 등록은 서버가 다음 발송 실패로 정리한다. - unregisterDevicePushTarget() + try { + // 푸시 대상 해제가 먼저다. 이 요청도 액세스 토큰을 달고 나가므로 세션이 살아 있어야 한다. + // 실패해도 로그아웃은 진행한다(best-effort). 남은 등록은 서버가 다음 발송 실패로 정리한다. + unregisterDevicePushTarget() - val refreshToken = getRefreshToken().getOrNull() - if (refreshToken != null) { - runCatchingCancellable { authApiService.logout(LogoutRequestDto(refreshToken)) } + val refreshToken = getRefreshToken().getOrNull() + if (refreshToken != null) { + runCatchingCancellable { authApiService.logout(LogoutRequestDto(refreshToken)) } + } + } finally { + // 두 정리 모두 반드시 위 API 호출 뒤여야 한다. 로그아웃 HTTP 요청도 AuthInterceptor 를 + // 지나므로 그 시점엔 토큰이 살아 있어야 하고, 토큰이 만료 임박이면 요청 직전 선제 reissue 가 + // tracker 에 새 deadline 을 기록할 수 있어 tracker 를 먼저 비우면 그 기록이 되살아난다. + // 취소된 코루틴에서도 suspend 정리가 돌도록 NonCancellable 로 감싼다. + withContext(NonCancellable) { + localStoreRegistry.clearScope(StoreScope.SESSION) + expiryTracker.clear() + } } - // 두 정리 모두 반드시 위 API 호출 뒤여야 한다. 로그아웃 HTTP 요청도 AuthInterceptor 를 - // 지나므로 그 시점엔 토큰이 살아 있어야 하고, 토큰이 만료 임박이면 요청 직전 선제 reissue 가 - // tracker 에 새 deadline 을 기록할 수 있다 — tracker 를 먼저 비우면 그 기록이 되살아난다. - localStoreRegistry.clearScope(StoreScope.SESSION) - expiryTracker.clear() } /** diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index 13b0360aed..66bc66c2a9 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -20,8 +20,11 @@ import com.afternote.core.network.model.BaseResponse import com.afternote.core.network.service.AuthApiService import com.afternote.core.network.service.TokenApiService import com.afternote.core.network.token.AccessTokenExpiryTracker +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -159,6 +162,63 @@ class AuthRepositoryImplTest { assertFalse(tracker.isExpiringSoon()) } + @Test + fun `logout - 서버 응답을 기다리는 중 호출자가 취소돼도 SESSION 스코프·deadline 정리는 끝낸다`() { + runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } + tracker.record(expiresInSeconds = 30) + val logoutArrived = CompletableDeferred() + val serverResponse = CompletableDeferred>() + val authApiService = + FakeAuthApiService( + onLogout = { + logoutArrived.complete(Unit) + serverResponse.await() + }, + ) + val repository = repository(authApiService) + + runBlocking { + // 설정 화면 ViewModel 이 정리되며 viewModelScope 가 취소되는 상황 (#2243). + val caller = launch { repository.logout() } + logoutArrived.await() + caller.cancelAndJoin() + + assertTrue(caller.isCancelled) + } + + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) + assertNull(runBlocking { tokenDataSource.getRefreshToken() }) + assertFalse(tracker.isExpiringSoon()) + } + + @Test + fun `logout - 푸시 대상 해제를 기다리는 중 호출자가 취소돼도 SESSION 스코프·deadline 정리는 끝낸다`() { + runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "stored-refresh") } + tracker.record(expiresInSeconds = 30) + val unregisterArrived = CompletableDeferred() + val unregisterResponse = CompletableDeferred() + val pushTargetRepository = + FakePushTargetRepository( + onUnregister = { + unregisterArrived.complete(Unit) + unregisterResponse.await() + }, + ) + val repository = repository(pushTargetRepository = pushTargetRepository) + + runBlocking { + val caller = launch { repository.logout() } + unregisterArrived.await() + caller.cancelAndJoin() + + assertTrue(caller.isCancelled) + } + + assertEquals(listOf(StoreScope.SESSION), localStoreRegistry.clearedScopes) + assertNull(runBlocking { tokenDataSource.getAccessToken() }) + assertFalse(tracker.isExpiringSoon()) + } + @Test fun `clearSession - SESSION 스코프·deadline 함께 정리 (탈퇴 경로도 이 메서드를 쓴다)`() { runBlocking { tokenDataSource.saveTokens(accessToken = "access", refreshToken = "refresh") } @@ -436,7 +496,7 @@ private class FakeAuthApiService( private val onSocialLogin: () -> BaseResponse = { error("socialLogin 은 이 시나리오에서 호출되면 안 됨") }, - private val onLogout: () -> BaseResponse = { success(Unit) }, + private val onLogout: suspend () -> BaseResponse = { success(Unit) }, ) : AuthApiService { val logoutRequests = mutableListOf() @@ -513,6 +573,7 @@ private class ThrowingDevicePushTargetProvider : DevicePushTargetProvider { private class FakePushTargetRepository( private val failing: Boolean = false, + private val onUnregister: suspend () -> Unit = {}, ) : PushTargetRepository { val unregistered = mutableListOf() @@ -521,6 +582,7 @@ private class FakePushTargetRepository( override suspend fun unregister(targetId: String): Result { unregistered += targetId + onUnregister() return if (failing) Result.failure(IllegalStateException("해제 실패")) else Result.success(Unit) } } From 645fe0fd65c4f7b16be48a1a4a9c05aac035adb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 3 Oct 2026 20:02:58 +0900 Subject: [PATCH 058/116] =?UTF-8?q?fix(core):=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=ED=95=9C=20=EC=84=B8=EC=85=98=EC=97=90=EB=A7=8C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4=20(#2237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재발급은 로그인·로그아웃과 락을 공유하지 않아, 응답을 기다리는 사이 세션이 끝나거나 바뀔 수 있다. 늦은 성공이 로그아웃한 계정을 legacy 세션으로 되살리고 (#2237), 이전 세션의 늦은 거절이 새로 로그인한 세션을 지웠다(#2238). - TokenReissuer 가 재발급 시작 시 세션 식별자를 잡아 회전·거절 정리·deadline 정리를 그 세션에만 적용한다. - TokenDataSource 에 세션 대조 쓰기와 대조 정리를 추가했다. 대조와 쓰기는 같은 edit 안에서 한다. - 세션이 바뀌어 쓰지 못한 회전은 SessionChangedException 으로 실패하고, 이전 토큰 호출자에게는 거절로 돌아간다. Closes #2238 --- .../data/repoimpl/auth/AuthRepositoryImpl.kt | 46 ++- .../ReceiverSessionBoundaryRaceTest.kt | 4 +- .../repoimpl/auth/AuthRepositoryImplTest.kt | 4 +- .../repoimpl/auth/SessionBoundReissueTest.kt | 325 ++++++++++++++++++ .../core/datastore/TokenDataSource.kt | 68 +++- .../core/datastore/TokenDataSourceTest.kt | 43 +++ .../domain/error/SessionChangedException.kt | 9 + .../domain/repository/auth/AuthRepository.kt | 17 +- .../testing/FakeCoreRepositoriesTest.kt | 41 ++- .../core/domain/testing/FakeAuthRepository.kt | 36 +- .../core/network/token/TokenReissuer.kt | 44 ++- .../core/network/NetworkTestFakes.kt | 1 + .../core/network/token/TokenReissuerTest.kt | 123 ++++++- 13 files changed, 724 insertions(+), 37 deletions(-) create mode 100644 core/data/src/test/java/com/afternote/core/data/repoimpl/auth/SessionBoundReissueTest.kt create mode 100644 core/domain/src/main/java/com/afternote/core/domain/error/SessionChangedException.kt diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt index dbdf296cf9..d90ebee29f 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.afternote.core.data.mapper.auth.AuthMapper import com.afternote.core.datastore.LocalStoreRegistry import com.afternote.core.datastore.StoreScope import com.afternote.core.datastore.TokenDataSource +import com.afternote.core.domain.error.SessionChangedException import com.afternote.core.domain.push.DevicePushTargetProvider import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.repository.push.PushTargetRepository @@ -43,6 +44,22 @@ internal class AuthRepositoryImpl expiryTracker.clear() } + /** + * 토큰 저장소의 대조·정리를 먼저 한 번의 쓰기로 끝내고, 정리됐을 때만 나머지 SESSION 저장소와 deadline 을 + * 비운다. 대조가 어긋났다면 그 저장소와 deadline 은 새 세션의 것이다 (#2238). + */ + override suspend fun clearSessionIfCurrent(sessionId: String) = + runCatchingCancellable { + val cleared = tokenDataSource.clearIfSession(sessionId) + if (cleared) { + localStoreRegistry.clearScope(StoreScope.SESSION) + expiryTracker.clear() + } + cleared + } + + override suspend fun getSessionId() = runCatchingCancellable { tokenDataSource.currentSessionId() } + override suspend fun getAccessToken() = runCatchingCancellable { tokenDataSource.getAccessToken() } override suspend fun getRefreshToken() = runCatchingCancellable { tokenDataSource.getRefreshToken() } @@ -108,23 +125,38 @@ internal class AuthRepositoryImpl AuthMapper.toSocialLoginResult(data) }.mapLoginFailure() - override suspend fun rotateToken(): Result = + /** + * 재발급 HTTP 가 떠 있는 동안 로그아웃이나 새 로그인이 끝날 수 있다(둘 다 재발급 락 밖이다). 그래서 + * 리프레시 토큰은 [sessionId] 세션의 것만 읽고, 응답은 그 세션이 아직 저장돼 있을 때만 쓴다 (#2237). + * 쓰지 못한 회전은 성공으로 돌려주지 않는다. 호출자가 그 토큰으로 deadline 을 기록하거나 재시도하면 안 된다. + */ + override suspend fun rotateToken(sessionId: String): Result = runCatchingCancellable { val refreshToken = - getRefreshToken().getOrNull() - ?: error("리프레시 토큰이 존재하지 않습니다.") + tokenDataSource.refreshTokenOf(sessionId) + ?: throw missingRefreshFailure(sessionId) val response = tokenApiService.reissue(ReissueRequestDto(refreshToken)) val tokenBundleResult = AuthMapper.toRotateTokenResult(response.requireData()) check(tokenBundleResult.accessToken.isNotEmpty()) { "Token rotation returned an empty access token" } - updateTokens( - accessToken = tokenBundleResult.accessToken, - refreshToken = tokenBundleResult.refreshToken, - ).getOrThrow() + val applied = + tokenDataSource.updateTokensIfSession( + expectedSessionId = sessionId, + accessToken = tokenBundleResult.accessToken, + refreshToken = tokenBundleResult.refreshToken, + ) + if (!applied) throw SessionChangedException() tokenBundleResult } + private suspend fun missingRefreshFailure(sessionId: String): Exception = + if (tokenDataSource.currentSessionId() == sessionId) { + IllegalStateException("리프레시 토큰이 존재하지 않습니다.") + } else { + SessionChangedException() + } + /** * 서버 로그아웃은 best-effort (네트워크 실패해도 사용자는 로그아웃 상태로 가야 함). * SESSION 스코프 로컬 저장소와 선제 reissue deadline 은 서버 호출 결과와 무관하게 항상 정리한다. diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt index 5b5f46b51a..daee7b0188 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverSessionBoundaryRaceTest.kt @@ -218,7 +218,7 @@ class ReceiverSessionBoundaryRaceTest { try { assertEquals(listOf(ACCOUNT_A_RECEIVER), emissions.receiveNames()) - authRepository.rotateToken().getOrThrow() + authRepository.rotateToken(checkNotNull(authRepository.getSessionId().getOrThrow())).getOrThrow() createReceiver(repository, "회전 뒤 등록한 수신자") assertEquals( @@ -264,7 +264,7 @@ class ReceiverSessionBoundaryRaceTest { emissions.receiveNames(), ) - authRepository.rotateToken().getOrThrow() + authRepository.rotateToken(checkNotNull(authRepository.getSessionId().getOrThrow())).getOrThrow() createReceiver(repository, "회전 뒤 등록한 수신자") assertEquals( diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt index 13b0360aed..703c200c34 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/AuthRepositoryImplTest.kt @@ -183,7 +183,9 @@ class AuthRepositoryImplTest { }, ) - val result = runBlocking { repository.rotateToken() } + val sessionId = checkNotNull(runBlocking { tokenDataSource.currentSessionId() }) + + val result = runBlocking { repository.rotateToken(sessionId) } assertTrue(result.exceptionOrNull() is IllegalStateException) assertEquals("old-access", runBlocking { tokenDataSource.getAccessToken() }) diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/SessionBoundReissueTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/SessionBoundReissueTest.kt new file mode 100644 index 0000000000..f610c3e07f --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/auth/SessionBoundReissueTest.kt @@ -0,0 +1,325 @@ +package com.afternote.core.data.repoimpl.auth + +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.stringPreferencesKey +import com.afternote.core.datastore.LocalStoreRegistry +import com.afternote.core.datastore.StoreScope +import com.afternote.core.datastore.TokenDataSource +import com.afternote.core.domain.error.SessionChangedException +import com.afternote.core.domain.push.DevicePushTargetProvider +import com.afternote.core.domain.repository.push.PushTargetRepository +import com.afternote.core.network.dto.LoginDto +import com.afternote.core.network.dto.LoginRequestDto +import com.afternote.core.network.dto.LogoutRequestDto +import com.afternote.core.network.dto.ReissueDto +import com.afternote.core.network.dto.ReissueRequestDto +import com.afternote.core.network.dto.SocialLoginRequestDto +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.AuthApiService +import com.afternote.core.network.service.TokenApiService +import com.afternote.core.network.token.AccessTokenExpiryTracker +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * 재발급 응답은 그 재발급을 시작한 세션에만 적용된다 (#2237). + * + * 재발급 HTTP 가 떠 있는 동안 로그아웃이나 새 로그인이 끝날 수 있다. 둘 다 재발급 락을 거치지 않기 때문이다. + * 그 뒤 도착한 성공 응답을 그대로 쓰면 로그아웃한 계정이 되살아나거나(세션 식별자가 없어 legacy 세션으로 읽힌다) + * 새 계정의 토큰이 이전 계정 토큰으로 덮인다. 저장소는 실제 파일 DataStore 이고, 쓰기 직전에 다른 쓰기를 + * 끼워 넣는 장치로 확인과 저장 사이의 창까지 결정적으로 연다. + */ +class SessionBoundReissueTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val storeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val tracker = AccessTokenExpiryTracker() + private val reissue = ScriptedReissue() + private lateinit var store: InterleavingDataStore + private lateinit var tokenDataSource: TokenDataSource + private lateinit var repository: AuthRepositoryImpl + + @Before + fun setUp() { + val file = File(temporaryFolder.root, "Token.preferences_pb") + store = InterleavingDataStore(PreferenceDataStoreFactory.create(scope = storeScope) { file }) + tokenDataSource = TokenDataSource(store) + repository = + AuthRepositoryImpl( + tokenDataSource = tokenDataSource, + authApiService = LogoutOnlyAuthApiService, + tokenApiService = reissue, + expiryTracker = tracker, + // 실제 레지스트리처럼 SESSION 정리가 토큰 저장소의 키를 비운다. + localStoreRegistry = + object : LocalStoreRegistry { + override fun store( + name: String, + scope: StoreScope, + ): DataStore = error("store 는 이 테스트에서 호출되면 안 됨") + + override suspend fun clearScope(scope: StoreScope) { + if (scope == StoreScope.SESSION) store.edit { it.clear() } + } + }, + pushTargetRepository = NoopPushTargetRepository, + devicePushTargetProvider = NoDevicePushTargetProvider, + ) + } + + @After + fun tearDown() = storeScope.cancel() + + @Test + fun `로그아웃 뒤 도착한 재발급 성공은 이전 세션을 되살리지 않는다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val rotation = async(Dispatchers.IO) { repository.rotateToken(currentSessionId()) } + reissue.awaitStarted() + + repository.logout().getOrThrow() + assertNull(tokenDataSource.getAccessToken()) + + reissue.respond(ReissueDto(accessToken = "A-rotated", refreshToken = "A-refresh-rotated", expiresIn = 3599)) + val result = withTimeout(TIMEOUT_MILLIS) { rotation.await() } + + assertTrue("쓰이지 않은 회전이 성공으로 보고됐다: $result", result.exceptionOrNull() is SessionChangedException) + assertNull(tokenDataSource.getAccessToken()) + assertNull(tokenDataSource.getRefreshToken()) + assertNull(tokenDataSource.sessionId.first()) + assertFalse(tokenDataSource.isLoggedIn.first()) + } + + @Test + fun `재발급 대기 중 새로 로그인한 세션은 늦은 성공에 덮이지 않는다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val rotation = async(Dispatchers.IO) { repository.rotateToken(currentSessionId()) } + reissue.awaitStarted() + + repository.logout().getOrThrow() + repository.saveSession(accessToken = "B-access", refreshToken = "B-refresh").getOrThrow() + val sessionB = tokenDataSource.sessionId.first() + + reissue.respond(ReissueDto(accessToken = "A-rotated", refreshToken = "A-refresh-rotated")) + val result = withTimeout(TIMEOUT_MILLIS) { rotation.await() } + + assertTrue(result.exceptionOrNull() is SessionChangedException) + assertEquals("B-access", tokenDataSource.getAccessToken()) + assertEquals("B-refresh", tokenDataSource.getRefreshToken()) + assertEquals(sessionB, tokenDataSource.sessionId.first()) + } + + @Test + fun `세션 확인과 저장 사이에 끼어든 새 로그인을 덮지 않는다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val rotation = async(Dispatchers.IO) { repository.rotateToken(currentSessionId()) } + reissue.awaitStarted() + + // 응답 뒤 저장소에 처음 닿는 순간(읽기든 쓰기든)에 로그아웃과 B 로그인을 끼워 넣는다. + // 읽기라면 읽은 값(A)을 돌려준 뒤가 아니라 돌려주기 직전에 끼워, 확인한 값과 저장 시점의 값이 갈리게 한다. + store.interleaveOnce { + store.edit { it.clear() } + tokenDataSource.saveTokens(accessToken = "B-access", refreshToken = "B-refresh") + } + reissue.respond(ReissueDto(accessToken = "A-rotated", refreshToken = "A-refresh-rotated")) + val result = withTimeout(TIMEOUT_MILLIS) { rotation.await() } + + assertTrue("끼워 넣기가 실행되지 않았다", store.interleaved) + assertTrue(result.exceptionOrNull() is SessionChangedException) + assertEquals("B-access", tokenDataSource.getAccessToken()) + assertEquals("B-refresh", tokenDataSource.getRefreshToken()) + } + + @Test + fun `같은 세션의 회전은 토큰만 바꾸고 세션 식별자를 유지한다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val sessionA = tokenDataSource.sessionId.first() + assertNotNull(sessionA) + val rotation = async(Dispatchers.IO) { repository.rotateToken(currentSessionId()) } + + assertEquals(ReissueRequestDto("A-refresh"), reissue.awaitStarted()) + reissue.respond(ReissueDto(accessToken = "A-rotated", refreshToken = "A-refresh-rotated", expiresIn = 3599)) + val bundle = withTimeout(TIMEOUT_MILLIS) { rotation.await() }.getOrThrow() + + assertEquals("A-rotated", bundle.accessToken) + assertEquals("A-rotated", tokenDataSource.getAccessToken()) + assertEquals("A-refresh-rotated", tokenDataSource.getRefreshToken()) + assertEquals(sessionA, tokenDataSource.sessionId.first()) + } + + @Test + fun `식별자 없이 저장된 기존 세션도 회전하고 식별자 없는 채로 남는다`() = + runBlocking { + store.edit { prefs -> + prefs[stringPreferencesKey("access_token")] = "legacy-access" + prefs[stringPreferencesKey("refresh_token")] = "legacy-refresh" + } + val legacySession = currentSessionId() + val rotation = async(Dispatchers.IO) { repository.rotateToken(legacySession) } + + assertEquals(ReissueRequestDto("legacy-refresh"), reissue.awaitStarted()) + reissue.respond(ReissueDto(accessToken = "legacy-rotated", refreshToken = "legacy-refresh-rotated")) + withTimeout(TIMEOUT_MILLIS) { rotation.await() }.getOrThrow() + + assertEquals("legacy-rotated", tokenDataSource.getAccessToken()) + assertEquals(legacySession, tokenDataSource.sessionId.first()) + } + + @Test + fun `이전 세션의 정리 요청은 새로 로그인한 세션과 그 deadline 을 지우지 않는다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val sessionA = currentSessionId() + repository.logout().getOrThrow() + repository.saveSession(accessToken = "B-access", refreshToken = "B-refresh").getOrThrow() + val sessionB = currentSessionId() + tracker.record(expiresInSeconds = 30) + + val cleared = repository.clearSessionIfCurrent(sessionA).getOrThrow() + + assertFalse(cleared) + assertEquals("B-access", tokenDataSource.getAccessToken()) + assertEquals("B-refresh", tokenDataSource.getRefreshToken()) + assertEquals(sessionB, tokenDataSource.sessionId.first()) + assertTrue("B 의 선제 갱신 deadline 이 지워졌다", tracker.isExpiringSoon()) + } + + @Test + fun `세션 확인과 정리 사이에 끼어든 새 로그인을 지우지 않는다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + val sessionA = currentSessionId() + store.interleaveOnce { + store.edit { it.clear() } + tokenDataSource.saveTokens(accessToken = "B-access", refreshToken = "B-refresh") + } + + val cleared = repository.clearSessionIfCurrent(sessionA).getOrThrow() + + assertTrue("끼워 넣기가 실행되지 않았다", store.interleaved) + assertFalse(cleared) + assertEquals("B-access", tokenDataSource.getAccessToken()) + } + + @Test + fun `현재 세션의 정리 요청은 토큰과 deadline 을 함께 정리한다`() = + runBlocking { + repository.saveSession(accessToken = "A-access", refreshToken = "A-refresh").getOrThrow() + tracker.record(expiresInSeconds = 30) + + val cleared = repository.clearSessionIfCurrent(currentSessionId()).getOrThrow() + + assertTrue(cleared) + assertNull(tokenDataSource.getAccessToken()) + assertNull(tokenDataSource.getRefreshToken()) + assertFalse(tokenDataSource.isLoggedIn.first()) + assertFalse(tracker.isExpiringSoon()) + } + + private suspend fun currentSessionId(): String = checkNotNull(repository.getSessionId().getOrThrow()) + + private companion object { + const val TIMEOUT_MILLIS = 5_000L + } +} + +/** 재발급 요청이 들어온 사실과 응답 시점을 테스트가 따로 쥔다. */ +private class ScriptedReissue : TokenApiService { + private val started = CompletableDeferred() + private val response = CompletableDeferred() + + override suspend fun reissue(body: ReissueRequestDto): BaseResponse { + started.complete(body) + return BaseResponse(status = 200, code = 200, message = "성공", data = response.await()) + } + + suspend fun awaitStarted(): ReissueRequestDto = withTimeout(5_000L) { started.await() } + + fun respond(data: ReissueDto) { + response.complete(data) + } +} + +/** + * 다음 한 번의 접근에 다른 쓰기를 끼워 넣는 DataStore. + * + * 쓰기라면 그 쓰기보다 먼저 끼워 넣고, 읽기라면 읽은 값이 호출자에게 전달되기 직전에 끼워 넣는다. 읽기 쪽은 + * 호출자가 끼워 넣기 이전 값을 받으므로, 확인과 저장을 따로 하는 구현은 낡은 값으로 판정하게 된다. + */ +private class InterleavingDataStore( + private val delegate: DataStore, +) : DataStore { + @Volatile + private var pending: (suspend () -> Unit)? = null + + @Volatile + var interleaved: Boolean = false + private set + + fun interleaveOnce(action: suspend () -> Unit) { + pending = action + } + + private suspend fun runPending() { + val action = synchronized(this) { pending.also { pending = null } } ?: return + action() + interleaved = true + } + + override val data: Flow + get() = delegate.data.onEach { runPending() } + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + runPending() + return delegate.updateData(transform) + } +} + +private object LogoutOnlyAuthApiService : AuthApiService { + override suspend fun login(body: LoginRequestDto): BaseResponse = error("login 은 이 테스트에서 호출되면 안 됨") + + override suspend fun socialLogin(body: SocialLoginRequestDto): BaseResponse = + error("socialLogin 은 이 테스트에서 호출되면 안 됨") + + override suspend fun logout(body: LogoutRequestDto): BaseResponse = + BaseResponse(status = 200, code = 200, message = "성공", data = Unit) +} + +private object NoopPushTargetRepository : PushTargetRepository { + override suspend fun register(targetId: String): Result = error("register 는 이 테스트에서 호출되면 안 됨") + + override suspend fun unregister(targetId: String): Result = Result.success(Unit) +} + +private object NoDevicePushTargetProvider : DevicePushTargetProvider { + override suspend fun currentTargetId(): String? = error("currentTargetId 는 이 테스트에서 호출되면 안 됨") + + override suspend fun existingTargetId(): String? = null +} diff --git a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt index 2193103bea..3b76d9c3dc 100644 --- a/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt +++ b/core/datastore/src/main/java/com/afternote/core/datastore/TokenDataSource.kt @@ -58,14 +58,7 @@ class TokenDataSource * 식별자 키가 없던 시절에 저장된 세션은 [LEGACY_SESSION_ID] 로 읽는다. 로그인 상태는 그대로 유지되고, * 다음 로그인부터 발급된 식별자를 쓴다. */ - val sessionId: Flow = - preferencesFlow.map { prefs -> - if (prefs[Keys.ACCESS_TOKEN] == null) { - null - } else { - prefs[Keys.SESSION_ID] ?: LEGACY_SESSION_ID - } - } + val sessionId: Flow = preferencesFlow.map { prefs -> prefs.sessionIdOrNull() } val isLoggedIn: Flow = sessionId.map { it != null } @@ -84,6 +77,8 @@ class TokenDataSource /** * 같은 세션 안에서 토큰만 회전한다 ([saveTokens] 와 달리 [sessionId] 를 새로 발급하지 않는다). * 회전은 계정이 바뀌는 사건이 아니므로, 세션에 귀속된 캐시를 버리게 만들면 안 된다. + * + * 지금 세션을 묻지 않고 쓰므로 재발급 응답 적용에는 [updateTokensIfSession] 을 쓴다. */ suspend fun updateTokens( accessToken: String, @@ -95,10 +90,67 @@ class TokenDataSource } } + /** + * 저장된 세션이 [expectedSessionId] 일 때만 토큰을 회전하고, 실제로 썼는지 돌려준다 (#2237). + * + * 재발급 HTTP 가 떠 있는 동안 로그아웃이나 새 로그인이 끝날 수 있다. 둘 다 재발급 락을 거치지 않는다. + * 그 뒤 도착한 응답을 묻지 않고 쓰면 로그아웃한 계정이 식별자 없는 세션([LEGACY_SESSION_ID])으로 + * 되살아나거나 새 계정의 토큰이 덮인다. 대조와 쓰기를 같은 `edit` 안에서 하는 이유는 그 사이에 + * 로그인 쓰기가 끼어들 틈을 없애기 위해서다. + */ + suspend fun updateTokensIfSession( + expectedSessionId: String, + accessToken: String, + refreshToken: String, + ): Boolean { + var applied = false + dataStore.edit { prefs -> + applied = prefs.sessionIdOrNull() == expectedSessionId + if (applied) { + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken + } + } + return applied + } + + /** + * 저장된 세션이 [expectedSessionId] 일 때만 이 저장소의 키를 비우고, 실제로 비웠는지 돌려준다 (#2238). + * + * 이전 세션의 재발급 거절이 늦게 도착했을 때 그사이 로그인한 새 세션을 지우지 않기 위한 정리다. + * 대조와 정리를 같은 `edit` 안에서 하는 이유는 [updateTokensIfSession] 과 같다. + */ + suspend fun clearIfSession(expectedSessionId: String): Boolean { + var cleared = false + dataStore.edit { prefs -> + cleared = prefs.sessionIdOrNull() == expectedSessionId + if (cleared) prefs.clear() + } + return cleared + } + + /** 지금 저장된 세션의 식별자. [sessionId] 의 현재 값이다. */ + suspend fun currentSessionId(): String? = sessionId.first() + + /** + * 저장된 세션이 [expectedSessionId] 일 때 그 세션의 리프레시 토큰. 세션이 다르거나 토큰이 없으면 `null` 이다. + * + * 세션과 토큰을 한 번의 읽기에서 꺼낸다. 따로 읽으면 그 사이 로그인한 새 세션의 리프레시 토큰으로 + * 재발급을 칠 수 있고, 서버가 그 토큰을 회전(RTR)하면 새 세션에 남은 리프레시 토큰이 무효가 된다. + */ + suspend fun refreshTokenOf(expectedSessionId: String): String? { + val prefs = preferencesFlow.first() + return if (prefs.sessionIdOrNull() == expectedSessionId) prefs[Keys.REFRESH_TOKEN] else null + } + suspend fun getAccessToken(): String? = preferencesFlow.first()[Keys.ACCESS_TOKEN] suspend fun getRefreshToken(): String? = preferencesFlow.first()[Keys.REFRESH_TOKEN] + /** 액세스 토큰이 없으면 세션도 없다. 식별자 키가 없던 시절의 세션은 [LEGACY_SESSION_ID] 로 읽는다. */ + private fun Preferences.sessionIdOrNull(): String? = + if (this[Keys.ACCESS_TOKEN] == null) null else this[Keys.SESSION_ID] ?: LEGACY_SESSION_ID + private companion object { /** 세션 식별자 키가 생기기 전에 저장된 세션의 식별자. 그 세션이 끝날 때까지 이 값 하나로 고정된다. */ const val LEGACY_SESSION_ID = "legacy-session" diff --git a/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt b/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt index 60b28806ee..634f623741 100644 --- a/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt +++ b/core/datastore/src/test/java/com/afternote/core/datastore/TokenDataSourceTest.kt @@ -122,6 +122,49 @@ class TokenDataSourceTest { assertEquals(legacySessionId, tokenDataSource.sessionId.first()) } + /** 재발급 응답은 그것을 시작한 세션에만 쓴다 (#2237). 로그아웃 뒤에는 식별자 없는 세션으로도 되살아나지 않는다. */ + @Test + fun `세션 대조 회전은 다른 세션이나 로그아웃 상태에 쓰지 않는다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "A-access", refreshToken = "A-refresh") + val sessionA = checkNotNull(tokenDataSource.currentSessionId()) + + assertTrue(tokenDataSource.updateTokensIfSession(sessionA, accessToken = "A-rotated", refreshToken = "A-refresh-rotated")) + assertEquals("A-rotated", tokenDataSource.getAccessToken()) + assertEquals(sessionA, tokenDataSource.currentSessionId()) + + registry.clearScope(StoreScope.SESSION) + assertFalse(tokenDataSource.updateTokensIfSession(sessionA, accessToken = "A-late", refreshToken = "A-refresh-late")) + assertNull(tokenDataSource.getAccessToken()) + assertNull(tokenDataSource.getRefreshToken()) + assertFalse(tokenDataSource.isLoggedIn.first()) + + tokenDataSource.saveTokens(accessToken = "B-access", refreshToken = "B-refresh") + assertFalse(tokenDataSource.updateTokensIfSession(sessionA, accessToken = "A-late", refreshToken = "A-refresh-late")) + assertEquals("B-access", tokenDataSource.getAccessToken()) + assertNull(tokenDataSource.refreshTokenOf(sessionA)) + assertEquals("B-refresh", tokenDataSource.refreshTokenOf(checkNotNull(tokenDataSource.currentSessionId()))) + } + + /** 거절 정리는 그 거절을 받은 세션일 때만 한다 (#2238). */ + @Test + fun `세션 대조 정리는 지금 세션일 때만 비운다`() = + runBlocking { + tokenDataSource.saveTokens(accessToken = "A-access", refreshToken = "A-refresh") + val sessionA = checkNotNull(tokenDataSource.currentSessionId()) + registry.clearScope(StoreScope.SESSION) + tokenDataSource.saveTokens(accessToken = "B-access", refreshToken = "B-refresh") + val sessionB = checkNotNull(tokenDataSource.currentSessionId()) + + assertFalse(tokenDataSource.clearIfSession(sessionA)) + assertEquals("B-access", tokenDataSource.getAccessToken()) + assertEquals(sessionB, tokenDataSource.currentSessionId()) + + assertTrue(tokenDataSource.clearIfSession(sessionB)) + assertNull(tokenDataSource.getAccessToken()) + assertNull(tokenDataSource.currentSessionId()) + } + private fun assertNotNullSessionId(sessionId: String?) { assertTrue("로그인된 세션에는 식별자가 있어야 한다 (실제=$sessionId)", !sessionId.isNullOrBlank()) } diff --git a/core/domain/src/main/java/com/afternote/core/domain/error/SessionChangedException.kt b/core/domain/src/main/java/com/afternote/core/domain/error/SessionChangedException.kt new file mode 100644 index 0000000000..1d27af4714 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/error/SessionChangedException.kt @@ -0,0 +1,9 @@ +package com.afternote.core.domain.error + +/** + * 세션에 묶인 작업이 끝나기 전에 그 세션이 끝났거나 다른 세션으로 바뀌었다는 사실 (#2237, #2238). + * + * 재발급은 로그인·로그아웃과 락을 공유하지 않는다. 그래서 재발급을 시작한 세션이 응답 도착 전에 사라질 수 있고, + * 그 응답은 지금 세션에 적용하지 않는다. 이 예외는 "적용하지 않았다" 를 성공과 구분해 알린다. + */ +class SessionChangedException : Exception("session changed before the token rotation was applied") diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/auth/AuthRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/auth/AuthRepository.kt index ff3f5153c2..65536c74cb 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/auth/AuthRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/auth/AuthRepository.kt @@ -19,6 +19,15 @@ interface AuthRepository { suspend fun clearSession(): Result + /** + * 지금 세션이 [sessionId] 일 때만 [clearSession] 과 같은 정리를 하고, 실제로 정리했는지 돌려준다. + * 재발급 거절처럼 특정 세션에 대한 판정을 적용할 때 쓴다. 그사이 로그인한 새 세션은 지우지 않는다 (#2238). + */ + suspend fun clearSessionIfCurrent(sessionId: String): Result + + /** 지금 저장된 로그인 세션의 식별자. 세션이 없으면 `null`. 로그인할 때마다 새로 발급된다. */ + suspend fun getSessionId(): Result + suspend fun getAccessToken(): Result suspend fun getRefreshToken(): Result @@ -32,7 +41,13 @@ interface AuthRepository { suspend fun googleLogin(idToken: String): Result - suspend fun rotateToken(): Result + /** + * [sessionId] 세션의 리프레시 토큰으로 재발급하고 그 세션에만 새 토큰을 저장한다 (#2237). + * + * 시작할 때나 저장할 때 세션이 [sessionId] 가 아니면 저장하지 않고 + * [com.afternote.core.domain.error.SessionChangedException] 으로 실패한다. + */ + suspend fun rotateToken(sessionId: String): Result suspend fun logout(): Result } diff --git a/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt b/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt index e2da536f56..157e586a3a 100644 --- a/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt +++ b/core/domain/src/test/java/com/afternote/core/domain/testing/FakeCoreRepositoriesTest.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.error.SessionChangedException import com.afternote.core.model.TokenBundle import com.afternote.core.model.delivery.ConditionState import com.afternote.core.model.delivery.DeliveryConditionItem @@ -32,7 +33,7 @@ class FakeCoreRepositoriesTest { assertTrue(repository.loggedIn) repository.loggedIn = false - val rotateResult = runBlocking { repository.rotateToken() } + val rotateResult = runBlocking { repository.rotateToken(repository.sessionId) } assertTrue(rotateResult.isSuccess) assertEquals("rotated-access", repository.accessToken) @@ -44,7 +45,7 @@ class FakeCoreRepositoriesTest { fun `rotateToken 기본 경로는 refresh가 없거나 새 access가 비어 있으면 실패한다`() { val missingRefresh = FakeAuthRepository(loggedIn = false) - val missingRefreshResult = runBlocking { missingRefresh.rotateToken() } + val missingRefreshResult = runBlocking { missingRefresh.rotateToken(missingRefresh.sessionId) } assertTrue(missingRefreshResult.isFailure) assertNull(missingRefresh.accessToken) @@ -54,18 +55,48 @@ class FakeCoreRepositoriesTest { val emptyAccess = FakeAuthRepository( loggedIn = false, + accessToken = "current-access", refreshToken = "current-refresh", rotatedTokens = TokenBundle("", "rotated-refresh"), ) - val emptyAccessResult = runBlocking { emptyAccess.rotateToken() } + val emptyAccessResult = runBlocking { emptyAccess.rotateToken(emptyAccess.sessionId) } assertTrue(emptyAccessResult.isFailure) - assertNull(emptyAccess.accessToken) + assertEquals("current-access", emptyAccess.accessToken) assertEquals("current-refresh", emptyAccess.refreshToken) assertFalse(emptyAccess.loggedIn) } + @Test + fun `세션에 묶인 기본 경로는 다른 세션이면 쓰지도 지우지도 않는다`() { + val repository = + FakeAuthRepository( + loggedIn = true, + accessToken = "A-access", + refreshToken = "A-refresh", + rotatedTokens = TokenBundle("A-rotated", "A-refresh-rotated"), + ) + val sessionA = repository.sessionId + runBlocking { repository.saveSession("B-access", "B-refresh") } + + val rotateResult = runBlocking { repository.rotateToken(sessionA) } + val clearResult = runBlocking { repository.clearSessionIfCurrent(sessionA) } + + assertTrue(rotateResult.exceptionOrNull() is SessionChangedException) + assertEquals(false, clearResult.getOrThrow()) + assertEquals(0, repository.clearSessionCalls) + assertEquals("B-access", repository.accessToken) + assertEquals("B-refresh", repository.refreshToken) + + val currentClear = runBlocking { repository.clearSessionIfCurrent(repository.sessionId) } + + assertEquals(true, currentClear.getOrThrow()) + assertEquals(1, repository.clearSessionCalls) + assertNull(repository.accessToken) + assertNull(runBlocking { repository.getSessionId() }.getOrThrow()) + } + @Test fun `Auth onX는 기록 외 기본 메모리 동작을 완전히 대체한다`() { val updateRepository = @@ -92,7 +123,7 @@ class FakeCoreRepositoriesTest { onRotateToken = { Result.success(TokenBundle("new-access", "new-refresh")) }, ) - val rotateResult = runBlocking { rotateRepository.rotateToken() } + val rotateResult = runBlocking { rotateRepository.rotateToken(rotateRepository.sessionId) } assertTrue(rotateResult.isSuccess) assertEquals(1, rotateRepository.rotateTokenCalls) diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeAuthRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeAuthRepository.kt index ab4aee8b92..47d1a6a923 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeAuthRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeAuthRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.error.SessionChangedException import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.Session import com.afternote.core.model.TokenBundle @@ -14,6 +15,9 @@ import java.util.concurrent.atomic.AtomicInteger * 기본은 토큰과 로그인 상태를 메모리에 저장하며 모든 호출을 기록한다. 특정 실패, 경합, * 서버 응답은 `onX` 로 갈아끼운다. 호출 금지 경계가 필요한 테스트는 [strict] 로 시작해 * 실제로 쓰는 경로만 연다. + * + * 세션 식별자는 [sessionId] 로 들고 있다. 액세스 토큰이 있을 때만 세션이 있는 것으로 읽고, [saveSession] 이 + * 새 값을 발급한다. 세션에 묶인 호출([rotateToken], [clearSessionIfCurrent])의 기본 동작은 이 값으로 대조한다. */ class FakeAuthRepository( loggedIn: Boolean = false, @@ -27,6 +31,8 @@ class FakeAuthRepository( var onSaveSession: (suspend (String, String) -> Result)? = null, var onUpdateTokens: (suspend (String, String) -> Result)? = null, var onClearSession: (suspend () -> Result)? = null, + var onClearSessionIfCurrent: (suspend (String) -> Result)? = null, + var onGetSessionId: (suspend () -> Result)? = null, var onGetAccessToken: (suspend () -> Result)? = null, var onGetRefreshToken: (suspend () -> Result)? = null, var onDefaultLogin: (suspend (String, String) -> Result)? = null, @@ -37,6 +43,12 @@ class FakeAuthRepository( ) : AuthRepository { val loggedInState = MutableStateFlow(loggedIn) + /** 지금 세션의 식별자. 액세스 토큰이 없으면 [getSessionId] 는 이 값과 무관하게 `null` 이다. */ + @Volatile + var sessionId: String = DEFAULT_SESSION_ID + + private val sessionCounter = AtomicInteger() + private val isLoggedInCounter = AtomicInteger() var loggedIn: Boolean @@ -53,6 +65,7 @@ class FakeAuthRepository( val savedSessions = CopyOnWriteArrayList>() val updatedTokens = CopyOnWriteArrayList>() + val rotatedSessionIds = CopyOnWriteArrayList() val attemptedEmailLogins = CopyOnWriteArrayList>() val attemptedKakaoLogins = CopyOnWriteArrayList() val attemptedGoogleLogins = CopyOnWriteArrayList() @@ -84,6 +97,7 @@ class FakeAuthRepository( ): Result { savedSessions += accessToken to refreshToken onSaveSession?.let { return it(accessToken, refreshToken) } + sessionId = "session-${sessionCounter.incrementAndGet()}" this.accessToken = accessToken this.refreshToken = refreshToken loggedIn = true @@ -111,6 +125,20 @@ class FakeAuthRepository( return Result.success(Unit) } + /** 세션이 [sessionId] 일 때만 [clearSession] 을 부른다. 대조가 어긋나면 [clearSession] 호출 수도 늘지 않는다. */ + override suspend fun clearSessionIfCurrent(sessionId: String): Result { + onClearSessionIfCurrent?.let { return it(sessionId) } + if (currentSessionId() != sessionId) return Result.success(false) + return clearSession().map { true } + } + + override suspend fun getSessionId(): Result { + onGetSessionId?.let { return it() } + return Result.success(currentSessionId()) + } + + private fun currentSessionId(): String? = if (accessToken == null) null else sessionId + override suspend fun getAccessToken(): Result { accessTokenCounter.incrementAndGet() onGetAccessToken?.let { return it() } @@ -144,12 +172,16 @@ class FakeAuthRepository( return Result.success(googleSession) } - override suspend fun rotateToken(): Result { + override suspend fun rotateToken(sessionId: String): Result { rotateTokenCounter.incrementAndGet() + rotatedSessionIds += sessionId onRotateToken?.let { return it(this) } if (refreshToken == null) { return Result.failure(IllegalStateException("리프레시 토큰이 존재하지 않습니다.")) } + if (currentSessionId() != sessionId) { + return Result.failure(SessionChangedException()) + } if (rotatedTokens.accessToken.isEmpty()) { return Result.failure(IllegalStateException("Token rotation returned an empty access token")) } @@ -171,6 +203,7 @@ class FakeAuthRepository( companion object { private const val DEFAULT_ACCESS_TOKEN = "access" private const val DEFAULT_REFRESH_TOKEN = "refresh" + private const val DEFAULT_SESSION_ID = "session-0" /** 모든 경로를 닫고, 테스트가 쓰는 `onX` 만 명시적으로 연다. */ fun strict( @@ -186,6 +219,7 @@ class FakeAuthRepository( onSaveSession = { _, _ -> unexpectedCall("AuthRepository.saveSession") }, onUpdateTokens = { _, _ -> unexpectedCall("AuthRepository.updateTokens") }, onClearSession = { unexpectedCall("AuthRepository.clearSession") }, + onGetSessionId = { unexpectedCall("AuthRepository.getSessionId") }, onGetAccessToken = { unexpectedCall("AuthRepository.getAccessToken") }, onGetRefreshToken = { unexpectedCall("AuthRepository.getRefreshToken") }, onDefaultLogin = { _, _ -> unexpectedCall("AuthRepository.defaultLogin") }, diff --git a/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt b/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt index 69315032e1..b3952119a7 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/token/TokenReissuer.kt @@ -1,6 +1,7 @@ package com.afternote.core.network.token import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.error.SessionChangedException import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.network.model.ApiException import kotlinx.coroutines.runBlocking @@ -25,6 +26,13 @@ import javax.inject.Singleton * - **거절 결과를 그 액세스 토큰에 묶어 공유한다.** 같은 토큰으로 들어온 대기자는 재발급을 * 치지 않고 같은 [Outcome.AuthenticationRejected] 를 받아, 한 번의 실패가 하나의 분류로 * 확정된다. 일시 실패는 공유하지 않는다 — 재시도가 성립하는 실패라 캐시하면 복구를 막는다. + * + * 락은 로그인·로그아웃과 공유하지 않는다. 그래서 재발급 HTTP 가 떠 있는 동안 세션이 끝나거나 바뀔 수 있고, + * 늦게 온 결과는 시작한 세션에만 적용한다(#2237, #2238). 재발급을 시작할 때 세션 식별자를 잡아 두고 + * 성공은 그 세션에만 저장하며([AuthRepository.rotateToken]), 거절은 그 세션일 때만 정리하고 + * ([AuthRepository.clearSessionIfCurrent]), deadline 도 그 세션일 때만 지운다. 세션이 바뀌어 저장하지 못한 + * 회전은 이전 토큰 호출자에게 [Outcome.AuthenticationRejected] 로 돌아간다. 새 세션의 토큰으로 이전 세션의 + * 요청을 재시도하면 안 되기 때문이다. */ @Singleton class TokenReissuer @@ -90,25 +98,34 @@ class TokenReissuer // 저장 토큰 비교보다 먼저다 — 세션 정리 뒤엔 저장 토큰이 비어 있어 아래 가드를 그냥 통과한다. rejection?.let { if (expectedAccessToken == rejectedAccessToken) return it } + // 세션을 토큰보다 먼저 읽는다. 사이에 로그인이 끼면 토큰 비교에서 TokenAlreadyChanged 로 빠지고, + // 반대 순서면 이전 토큰 호출자가 새 세션의 회전을 치게 된다. + val sessionId = runBlocking { authRepository.get().getSessionId() }.getOrNull() val currentToken = runBlocking { authRepository.get().getAccessToken() }.getOrNull() if (!currentToken.isNullOrEmpty() && currentToken != expectedAccessToken) { return Outcome.TokenAlreadyChanged(currentToken) } + if (sessionId == null) { + // 회전할 세션이 없다(로그아웃 뒤). 리프레시 토큰도 없으므로 재발급 HTTP 를 치지 않는다. + val failure = Outcome.UnexpectedFailure(IllegalStateException("리프레시 토큰이 존재하지 않습니다.")) + reportObservableFailure(failure) + return failure + } - val rotationResult = runBlocking { authRepository.get().rotateToken() } + val rotationResult = runBlocking { authRepository.get().rotateToken(sessionId) } val rotationException = rotationResult.exceptionOrNull() if (rotationException != null) { - // 실패한 토큰의 deadline 을 지워 선제 재시도 반복을 막는다. - expiryTracker.clear() + // 실패한 토큰의 deadline 을 지워 선제 재시도 반복을 막는다. 세션이 바뀌었다면 남은 deadline 은 새 세션의 것이다. + clearDeadlineIfCurrent(sessionId) val failure = classifyFailure(rotationException) - rememberIfRejected(expectedAccessToken, failure) + rememberIfRejected(expectedAccessToken, sessionId, failure) reportObservableFailure(failure) return failure } val newBundle = rotationResult.getOrThrow() if (newBundle.accessToken.isEmpty()) { - expiryTracker.clear() + clearDeadlineIfCurrent(sessionId) val failure = Outcome.UnexpectedFailure( IllegalStateException("Token rotation returned an empty access token"), @@ -125,11 +142,22 @@ class TokenReissuer } } + private fun clearDeadlineIfCurrent(sessionId: String) { + val currentSessionId = runBlocking { authRepository.get().getSessionId() }.getOrNull() + if (currentSessionId == sessionId) expiryTracker.clear() + } + private fun classifyFailure(exception: Throwable): Outcome.Failure = when (exception) { + // 시작한 세션이 끝났다. 이전 토큰으로는 더 진행할 수 없으므로 거절과 같이 다룬다. + is SessionChangedException -> Outcome.AuthenticationRejected(exception) + is ApiException -> classifyApiFailure(exception) + is HttpException -> classifyHttpFailure(exception, exception.code()) + is IOException -> Outcome.TransportFailure(exception) + else -> Outcome.UnexpectedFailure(exception) } @@ -164,13 +192,17 @@ class TokenReissuer * * 정리를 락 밖(호출자)에 두면 락이 풀린 뒤 정리가 끝나기까지가 대기자의 재발급이 * 빠져나가는 창이다. 일시 실패는 여기 들어오지 않는다 — 세션도 유지하고 캐시도 하지 않는다. + * + * 정리는 [sessionId] 가 아직 지금 세션일 때만 한다. 이 거절은 재발급을 시작한 세션에 대한 판정이고, + * 응답을 기다리는 사이 새로 로그인한 세션을 지우면 안 된다 (#2238). */ private fun rememberIfRejected( expectedAccessToken: String, + sessionId: String, failure: Outcome.Failure, ) { if (failure !is Outcome.AuthenticationRejected) return - runBlocking { authRepository.get().clearSession() } + runBlocking { authRepository.get().clearSessionIfCurrent(sessionId) } rejectedAccessToken = expectedAccessToken rejection = failure } diff --git a/core/network/src/test/kotlin/com/afternote/core/network/NetworkTestFakes.kt b/core/network/src/test/kotlin/com/afternote/core/network/NetworkTestFakes.kt index 60419cafd8..6922300701 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/NetworkTestFakes.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/NetworkTestFakes.kt @@ -20,6 +20,7 @@ internal fun networkFakeAuthRepository( ): CoreFakeAuthRepository = CoreFakeAuthRepository.strict(accessToken = accessToken).apply { onGetAccessToken = null + onGetSessionId = null this.onRotateToken = onRotateToken this.onClearSession = { onClearSession(this) } } diff --git a/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt index 1b0ba0fb79..a8ee2986b6 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/token/TokenReissuerTest.kt @@ -1,5 +1,6 @@ package com.afternote.core.network.token +import com.afternote.core.domain.error.SessionChangedException import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.model.TokenBundle import com.afternote.core.network.FakeErrorReporter @@ -268,18 +269,62 @@ class TokenReissuerTest { } @Test - fun `저장 토큰이 빈 값 - TokenAlreadyChanged 가 아니라 회전 시도로 진행`() { + fun `저장 토큰이 빈 값 - TokenAlreadyChanged 가 아니라 회전할 세션이 없는 실패로 끝난다`() { + val repository = networkFakeAuthRepository(accessToken = null) + val coordinator = reissuer(repository) + + val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") + + // 세션이 없으면 리프레시 토큰도 없다. 재발급 HTTP 없이 세션 유지 분류로 끝난다. + assertEquals(0, repository.rotateCallCount) + assertTrue(outcome is TokenReissuer.Outcome.UnexpectedFailure) + } + + @Test + fun `재발급 시작 시점의 세션에 묶어 회전한다`() { val repository = networkFakeAuthRepository( - accessToken = null, - onRotateToken = { Result.failure(IllegalStateException("리프레시 토큰이 존재하지 않습니다.")) }, + accessToken = "old-token", + onRotateToken = { + accessToken = "fresh-token" + Result.success(TokenBundle(accessToken = "fresh-token", refreshToken = "r")) + }, ) - val coordinator = reissuer(repository) - val outcome = coordinator.reissueOnce(expectedAccessToken = "old-token") + reissuer(repository).reissueOnce(expectedAccessToken = "old-token") + assertEquals(listOf(repository.sessionId), repository.rotatedSessionIds) + } + + @Test + fun `세션이 바뀌어 저장하지 못한 회전 - Rotated 도 deadline 도 없이 이전 토큰 호출자에게 거절`() { + val reporter = FakeErrorReporter() + val repository = + networkFakeAuthRepository( + accessToken = "A-access", + onRotateToken = { + // 응답을 기다리는 사이 로그아웃이 끝났고, 저장소가 이 회전을 쓰지 않았다. + clearSession() + Result.failure(SessionChangedException()) + }, + onClearSession = { + accessToken = null + refreshToken = null + Result.success(Unit) + }, + ) + val coordinator = reissuer(repository, reporter) + + val outcome = coordinator.reissueOnce(expectedAccessToken = "A-access") + + assertTrue(outcome is TokenReissuer.Outcome.AuthenticationRejected) + assertTrue((outcome as TokenReissuer.Outcome.AuthenticationRejected).exception is SessionChangedException) + assertFalse(tracker.isExpiringSoon()) + assertTrue(reporter.writtenFailures.isEmpty()) + // 로그아웃 정리 1회뿐이고, 같은 토큰의 대기자는 재발급 없이 같은 결과를 받는다. + assertEquals(1, repository.clearSessionCallCount) + assertSame(outcome, coordinator.reissueOnce(expectedAccessToken = "A-access")) assertEquals(1, repository.rotateCallCount) - assertTrue(outcome is TokenReissuer.Outcome.UnexpectedFailure) } @Test @@ -356,4 +401,70 @@ class TokenReissuerTest { assertEquals(TokenReissuer.Outcome.Rotated("rotated-token"), outcome) assertEquals(2, repository.rotateCallCount) } + + @Test + fun `이전 세션의 늦은 확정 거절은 새로 로그인한 세션을 지우지 않는다`() { + listOf( + ApiException(status = 401, code = 1107, serverMessage = "무효 refresh", fallbackMessage = "무효 refresh"), + ApiException(status = 400, code = 1107, serverMessage = "무효 refresh", fallbackMessage = "무효 refresh"), + ).forEach { failure -> + tracker.clear() + val repository = + networkFakeAuthRepository( + accessToken = "A-access", + onRotateToken = { + // 재발급 응답을 기다리는 사이 로그아웃과 B 로그인이 끝났다. 로그인은 발급 응답의 deadline 도 기록한다. + clearSession() + saveSession("B-access", "B-refresh") + tracker.record(expiresInSeconds = 30) + Result.failure(failure) + }, + onClearSession = { + accessToken = null + refreshToken = null + Result.success(Unit) + }, + ).apply { onSaveSession = null } + val coordinator = reissuer(repository) + + val outcome = coordinator.reissueOnce(expectedAccessToken = "A-access") + + assertTrue(outcome is TokenReissuer.Outcome.AuthenticationRejected) + assertSame(failure, (outcome as TokenReissuer.Outcome.AuthenticationRejected).exception) + assertEquals("${failure.status}/${failure.code} 거절이 B 를 지웠다", "B-access", repository.accessToken) + assertEquals("B-refresh", repository.refreshToken) + // 로그아웃 정리 1회뿐이다. 거절 처리가 정리를 더 부르면 그것이 B 를 지운다. + assertEquals(1, repository.clearSessionCallCount) + assertTrue("B 의 선제 갱신 deadline 이 지워졌다", tracker.isExpiringSoon()) + + // A 토큰으로 늦게 들어온 대기자는 재발급 없이 같은 거절을 받는다 (#1126). + assertSame(outcome, coordinator.reissueOnce(expectedAccessToken = "A-access")) + assertEquals(1, repository.rotateCallCount) + } + } + + @Test + fun `이전 세션의 일시 실패는 새 세션의 deadline 을 지우지 않는다`() { + val repository = + networkFakeAuthRepository( + accessToken = "A-access", + onRotateToken = { + clearSession() + saveSession("B-access", "B-refresh") + tracker.record(expiresInSeconds = 30) + Result.failure(SocketTimeoutException("timeout")) + }, + onClearSession = { + accessToken = null + refreshToken = null + Result.success(Unit) + }, + ).apply { onSaveSession = null } + + val outcome = reissuer(repository).reissueOnce(expectedAccessToken = "A-access") + + assertTrue(outcome is TokenReissuer.Outcome.TransportFailure) + assertEquals("B-access", repository.accessToken) + assertTrue(tracker.isExpiringSoon()) + } } From ebbbbea6dcc875263eb2c8a7fb7ce31a626a2c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 04:09:16 +0000 Subject: [PATCH 059/116] =?UTF-8?q?refactor(setting):=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20Intent=20=EB=A5=BC=20=ED=83=AD=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=8C=80=EC=8B=A0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탭 순서(0=미활동, 1=수신자 요청)를 Screen(타입→인덱스)과 ViewModel(인덱스→타입)이 반대 방향으로 따로 알고 있어, 라디오 순서만 바꿔도 매핑이 조용히 어긋날 수 있었다. 매핑을 Screen 의 conditionTypes 한 곳으로 모으고 SelectConditionType 은 도메인 타입을 싣게 해 ViewModel 분기를 dispatch 한 줄로 줄인다. --- .../presentation/delivery/DeliveryConditionContract.kt | 2 +- .../presentation/delivery/DeliveryConditionScreen.kt | 8 +++++--- .../presentation/delivery/DeliveryConditionViewModel.kt | 8 +------- .../setting/presentation/SettingAccountSecurityTest.kt | 2 +- .../feature/setting/presentation/SettingCompletionTest.kt | 2 +- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt index 53e1170cda..5806ff18c6 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionContract.kt @@ -7,7 +7,7 @@ import com.afternote.core.ui.mvi.ReducerEvent internal sealed interface DeliveryConditionIntent : MviIntent { data class SelectConditionType( - val index: Int, + val type: DeliveryConditionType, ) : DeliveryConditionIntent data object Save : DeliveryConditionIntent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 8e3595cd72..f90fa76601 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -61,10 +61,12 @@ internal fun DeliveryConditionScreen( private fun DeliveryConditionContent( uiState: DeliveryConditionUiState, onBack: () -> Unit, - onConditionTypeSelect: (Int) -> Unit, + onConditionTypeSelect: (DeliveryConditionType) -> Unit, onLastGreetingEditClick: () -> Unit, onSave: () -> Unit, ) { + // 라디오 순서의 단일 출처. processingMethodItems 와 같은 순서로 둔다. + val conditionTypes = listOf(DeliveryConditionType.INACTIVITY, DeliveryConditionType.RECEIVER_REQUEST) val processingMethodItems = listOf( RadioGroupItem( @@ -127,8 +129,8 @@ private fun DeliveryConditionContent( Spacer(Modifier.height(28.dp)) RadioGroup( items = processingMethodItems, - selectedIndex = if (uiState.conditionType == DeliveryConditionType.INACTIVITY) 0 else 1, - onSelectIndex = onConditionTypeSelect, + selectedIndex = conditionTypes.indexOf(uiState.conditionType), + onSelectIndex = { onConditionTypeSelect(conditionTypes[it]) }, ) Spacer(Modifier.height(32.dp)) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 838fb5f4c6..6789a7eb8d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -31,7 +31,7 @@ internal class DeliveryConditionViewModel override fun onIntent(intent: DeliveryConditionIntent) { when (intent) { - is DeliveryConditionIntent.SelectConditionType -> onConditionTypeSelected(intent.index) + is DeliveryConditionIntent.SelectConditionType -> dispatch(DeliveryConditionReducerEvent.ConditionSelected(intent.type)) DeliveryConditionIntent.Save -> onSave() DeliveryConditionIntent.ConsumeSuccess -> dispatch(DeliveryConditionReducerEvent.SuccessConsumed) } @@ -98,12 +98,6 @@ internal class DeliveryConditionViewModel } } - private fun onConditionTypeSelected(index: Int) { - val conditionType = - if (index == 1) DeliveryConditionType.RECEIVER_REQUEST else DeliveryConditionType.INACTIVITY - dispatch(DeliveryConditionReducerEvent.ConditionSelected(conditionType)) - } - private fun onSave() { val state = currentState if (!state.isInitialized || state.isSaving || state.isSaved) return diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt index 2b7ae6893b..b1e985377d 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingAccountSecurityTest.kt @@ -217,7 +217,7 @@ class SettingAccountSecurityTest { } composeRule.runOnIdle { - viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(DeliveryConditionType.RECEIVER_REQUEST)) viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index f45105d4c8..94a3c0b89c 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -559,7 +559,7 @@ class SettingCompletionTest { val expectedCall = CompletionDeliveryUpdateCall(RECEIVER_ID, expectedConditions) composeRule.runOnIdle { - viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(index = 1)) + viewModel.onIntent(DeliveryConditionIntent.SelectConditionType(DeliveryConditionType.RECEIVER_REQUEST)) viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { From 50ed2bbe4f23d5093ea9a59f44a367129574e361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:42:43 +0000 Subject: [PATCH 060/116] =?UTF-8?q?ci(review):=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=EB=90=9C=20develop=20PR=20=EC=97=90=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A8=B8=EC=A7=80=EB=A5=BC=20=EC=98=88=EC=95=BD=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/decide-auto-merge-on-approval.mjs | 69 +++++++++++++++ .../decide-auto-merge-on-approval.test.mjs | 83 +++++++++++++++++++ .../latest-review-decision-reconcile.yml | 57 +++++++++++++ README.md | 1 + 4 files changed, 210 insertions(+) create mode 100644 .github/scripts/decide-auto-merge-on-approval.mjs create mode 100644 .github/scripts/decide-auto-merge-on-approval.test.mjs diff --git a/.github/scripts/decide-auto-merge-on-approval.mjs b/.github/scripts/decide-auto-merge-on-approval.mjs new file mode 100644 index 0000000000..645c2adff4 --- /dev/null +++ b/.github/scripts/decide-auto-merge-on-approval.mjs @@ -0,0 +1,69 @@ +// 승인 직후 자동 머지 예약 판정 +// +// 리뷰어가 승인하면 «Merge when ready» 를 대신 눌러 둔다. 실제 머지는 GitHub 가 required check· +// 승인 수·merge queue 를 모두 통과시킨 뒤에만 하므로, 이 판정은 «예약을 걸어도 되는가» 만 본다. +// +// 리뷰어의 파일 «Viewed» 체크는 조건에 넣지 못한다. GraphQL 의 viewerViewedState 는 토큰 주인 +// 본인의 상태만 돌려주므로 GITHUB_TOKEN(github-actions)으로는 리뷰어의 체크를 볼 수 없고, +// 체크 변경을 알리는 webhook 이벤트도 없다. 승인을 «다 봤다» 는 신호로 쓴다. +import { appendFileSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +/** + * @param {object} pullRequest GraphQL repository.pullRequest 노드 + * @param {string} defaultBranch 자동 머지를 허용할 base(통합 브랜치) + * @returns {{ enable: boolean, reason: string, headSha?: string }} + */ +export function decideAutoMerge(pullRequest, defaultBranch) { + if (!pullRequest) return { enable: false, reason: "PR 을 찾지 못했다" }; + if (pullRequest.state !== "OPEN") return { enable: false, reason: `PR 상태가 ${pullRequest.state}` }; + if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; + // 릴리스(main)·스택 상위 PR 은 사람이 머지 시점을 정한다. + if (pullRequest.baseRefName !== defaultBranch) { + return { enable: false, reason: `base 가 ${pullRequest.baseRefName} (자동 머지는 ${defaultBranch} 만)` }; + } + if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { + return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; + } + // 방금 들어온 리뷰가 승인일 때만 건다. 사람이 예약을 끈 뒤 달린 일반 코멘트 리뷰가 예약을 + // 되살리지 않게 한다. + const lastReview = pullRequest.reviews?.nodes?.at(-1); + if (lastReview?.state !== "APPROVED") { + return { enable: false, reason: `마지막 리뷰가 승인이 아니다 (${lastReview?.state ?? "없음"})` }; + } + // 최신 판정 리컨사일 뒤의 PR 전체 판정. 변경요청이 남아 있으면 걸지 않는다. + if (pullRequest.reviewDecision !== "APPROVED") { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + } + // 승인 뒤 새 커밋이 올라왔으면 그 커밋은 승인받지 않았다. + if (lastReview.commit?.oid !== pullRequest.headRefOid) { + return { enable: false, reason: "승인 뒤 새 커밋이 있다" }; + } + return { enable: true, reason: "승인됨 — 자동 머지 예약", headSha: pullRequest.headRefOid }; +} + +function main() { + const [responsePath] = process.argv.slice(2); + const defaultBranch = process.env.DEFAULT_BRANCH; + if (!responsePath || !defaultBranch) { + throw new Error("사용법: DEFAULT_BRANCH= node decide-auto-merge-on-approval.mjs "); + } + const response = JSON.parse(readFileSync(responsePath, "utf8")); + const decision = decideAutoMerge(response?.data?.repository?.pullRequest, defaultBranch); + console.log(`auto-merge: ${decision.reason}`); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `enable=${decision.enable}\nhead_sha=${decision.headSha ?? ""}\n`); + } +} + +const isDirectExecution = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/decide-auto-merge-on-approval.test.mjs b/.github/scripts/decide-auto-merge-on-approval.test.mjs new file mode 100644 index 0000000000..9f49faf634 --- /dev/null +++ b/.github/scripts/decide-auto-merge-on-approval.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { decideAutoMerge } from "./decide-auto-merge-on-approval.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const OLD = "fedcba9876543210fedcba9876543210fedcba98"; + +function approvedPullRequest(overrides = {}) { + return { + state: "OPEN", + isDraft: false, + baseRefName: "develop", + headRefOid: HEAD, + reviewDecision: "APPROVED", + autoMergeRequest: null, + isInMergeQueue: false, + reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }] }, + ...overrides, + }; +} + +test("develop 대상 PR 이 최신 커밋에서 승인되면 자동 머지를 예약한다", () => { + assert.deepEqual(decideAutoMerge(approvedPullRequest(), "develop"), { + enable: true, + reason: "승인됨 — 자동 머지 예약", + headSha: HEAD, + }); +}); + +test("닫힘·Draft·다른 base 는 예약하지 않는다", () => { + for (const overrides of [ + { state: "CLOSED" }, + { state: "MERGED" }, + { isDraft: true }, + { baseRefName: "main" }, + { baseRefName: "feat/123-parent" }, + ]) { + assert.equal(decideAutoMerge(approvedPullRequest(overrides), "develop").enable, false, JSON.stringify(overrides)); + } + assert.equal(decideAutoMerge(null, "develop").enable, false); +}); + +test("마지막 리뷰가 승인이 아니면 예약하지 않는다 — 끈 예약을 코멘트가 되살리지 않는다", () => { + for (const state of ["COMMENTED", "CHANGES_REQUESTED", "DISMISSED"]) { + const pullRequest = approvedPullRequest({ + reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }, { state, commit: { oid: HEAD } }] }, + }); + assert.equal(decideAutoMerge(pullRequest, "develop").enable, false, state); + } + assert.equal(decideAutoMerge(approvedPullRequest({ reviews: { nodes: [] } }), "develop").enable, false); +}); + +test("PR 전체 판정이 승인이 아니면 예약하지 않는다", () => { + for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { + assert.equal(decideAutoMerge(approvedPullRequest({ reviewDecision }), "develop").enable, false, String(reviewDecision)); + } +}); + +test("승인 뒤 새 커밋이 있으면 예약하지 않는다", () => { + const pullRequest = approvedPullRequest({ reviews: { nodes: [{ state: "APPROVED", commit: { oid: OLD } }] } }); + assert.equal(decideAutoMerge(pullRequest, "develop").enable, false); +}); + +test("이미 예약됐거나 큐에 있으면 다시 걸지 않는다", () => { + assert.equal(decideAutoMerge(approvedPullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), "develop").enable, false); + assert.equal(decideAutoMerge(approvedPullRequest({ isInMergeQueue: true }), "develop").enable, false); +}); + +test("워크플로는 리컨사일 뒤에만, 승인한 HEAD 로만 예약한다", async () => { + const workflow = await readFile( + new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), + "utf8", + ); + const job = workflow.slice(workflow.indexOf(" auto-merge:")); + assert.match(job, /needs: reconcile/); + assert.match(job, /github\.event_name == 'workflow_run'/); + assert.match(job, /decide-auto-merge-on-approval\.mjs/); + assert.match(job, /--match-head-commit "\$HEAD_SHA"/); + // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. + assert.doesNotMatch(job, /--admin/); +}); diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index 270dfc1ece..19b7331c12 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -44,3 +44,60 @@ jobs: TARGET_PULL_NUMBER: >- ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} run: node .github/scripts/reconcile-latest-review-decision.mjs + + # 승인 직후 «Merge when ready» 를 대신 건다. 머지 자체는 GitHub 가 승인 수·required check· + # merge queue 를 모두 통과한 뒤에 하므로 CI 가 빨갛거나 변경요청이 남으면 머지되지 않는다. + # 리컨사일이 낡은 변경요청을 걷은 뒤의 판정을 보려고 needs 로 잇는다. 리뷰어의 파일 Viewed + # 체크는 토큰 주인 본인 것만 조회되고 이벤트도 없어 조건에 넣지 못한다(판정 스크립트 머리말). + auto-merge: + name: Enable auto-merge on approval + needs: reconcile + if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + steps: + - name: Clone decision script from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Decide + id: decide + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query=' + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + state isDraft baseRefName headRefOid reviewDecision isInMergeQueue + autoMergeRequest { enabledAt } + reviews(last: 1) { nodes { state commit { oid } } } + } + } + }' > "$RUNNER_TEMP/pull-request.json" + node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" + + - name: Enable auto-merge + if: steps.decide.outputs.enable == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + HEAD_SHA: ${{ steps.decide.outputs.head_sha }} + run: | + # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. + # 스택 PR 처럼 GitHub 가 거부하는 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. + if ! gh pr merge "$PR_NUMBER" --match-head-commit "$HEAD_SHA"; then + echo "::warning::#$PR_NUMBER 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" + fi diff --git a/README.md b/README.md index b7d2e69a98..7300ec3f48 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 - PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. - Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. +- `develop` 대상 PR은 최신 커밋에 승인이 달리면 [리뷰 판정 리컨사일 workflow](.github/workflows/latest-review-decision-reconcile.yml)가 자동 머지(Merge when ready)를 예약한다. 필수 검사가 통과해야 merge queue로 들어가고, 원치 않으면 PR 화면에서 예약을 끄면 된다. 리뷰어의 파일 Viewed 체크는 본인 토큰으로만 조회되고 이벤트도 없어 조건에 넣지 못하므로, 승인은 모든 파일을 확인한 뒤에 한다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. - [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. - `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. From b2e293b7a9bbdd1df16255f8dae389ffd69767f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:44:05 +0000 Subject: [PATCH 061/116] =?UTF-8?q?ci(review):=20GraphQL=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20env=20=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20SC2?= =?UTF-8?q?016=20=EC=9D=84=20=EC=97=86=EC=95=A4=EB=8B=A4=20(#2248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/latest-review-decision-reconcile.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index 19b7331c12..dfc61e24f5 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -74,9 +74,7 @@ jobs: NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query=' + QUERY: | query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -85,7 +83,11 @@ jobs: reviews(last: 1) { nodes { state commit { oid } } } } } - }' > "$RUNNER_TEMP/pull-request.json" + } + run: | + set -euo pipefail + gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query="$QUERY" \ + > "$RUNNER_TEMP/pull-request.json" node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" - name: Enable auto-merge From d66273af142d5c07a0c31855dd0b4a23c79202d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:52:26 +0000 Subject: [PATCH 062/116] =?UTF-8?q?ci(review):=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EB=8C=80=EC=8B=A0=201hyok=20=EC=9D=98=20V?= =?UTF-8?q?iewed=20=EC=A0=84=EC=B2=B4=20=EC=B2=B4=ED=81=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A8=B8=EC=A7=80=EB=A5=BC=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=ED=95=9C=EB=8B=A4=20(#2248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/decide-auto-merge-on-approval.mjs | 69 ---------- .../decide-auto-merge-on-approval.test.mjs | 83 ------------ .../find-viewed-auto-merge-targets.mjs | 123 ++++++++++++++++++ .../find-viewed-auto-merge-targets.test.mjs | 64 +++++++++ .../latest-review-decision-reconcile.yml | 59 --------- .github/workflows/viewed-auto-merge.yml | 75 +++++++++++ README.md | 2 +- 7 files changed, 263 insertions(+), 212 deletions(-) delete mode 100644 .github/scripts/decide-auto-merge-on-approval.mjs delete mode 100644 .github/scripts/decide-auto-merge-on-approval.test.mjs create mode 100644 .github/scripts/find-viewed-auto-merge-targets.mjs create mode 100644 .github/scripts/find-viewed-auto-merge-targets.test.mjs create mode 100644 .github/workflows/viewed-auto-merge.yml diff --git a/.github/scripts/decide-auto-merge-on-approval.mjs b/.github/scripts/decide-auto-merge-on-approval.mjs deleted file mode 100644 index 645c2adff4..0000000000 --- a/.github/scripts/decide-auto-merge-on-approval.mjs +++ /dev/null @@ -1,69 +0,0 @@ -// 승인 직후 자동 머지 예약 판정 -// -// 리뷰어가 승인하면 «Merge when ready» 를 대신 눌러 둔다. 실제 머지는 GitHub 가 required check· -// 승인 수·merge queue 를 모두 통과시킨 뒤에만 하므로, 이 판정은 «예약을 걸어도 되는가» 만 본다. -// -// 리뷰어의 파일 «Viewed» 체크는 조건에 넣지 못한다. GraphQL 의 viewerViewedState 는 토큰 주인 -// 본인의 상태만 돌려주므로 GITHUB_TOKEN(github-actions)으로는 리뷰어의 체크를 볼 수 없고, -// 체크 변경을 알리는 webhook 이벤트도 없다. 승인을 «다 봤다» 는 신호로 쓴다. -import { appendFileSync, readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -/** - * @param {object} pullRequest GraphQL repository.pullRequest 노드 - * @param {string} defaultBranch 자동 머지를 허용할 base(통합 브랜치) - * @returns {{ enable: boolean, reason: string, headSha?: string }} - */ -export function decideAutoMerge(pullRequest, defaultBranch) { - if (!pullRequest) return { enable: false, reason: "PR 을 찾지 못했다" }; - if (pullRequest.state !== "OPEN") return { enable: false, reason: `PR 상태가 ${pullRequest.state}` }; - if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; - // 릴리스(main)·스택 상위 PR 은 사람이 머지 시점을 정한다. - if (pullRequest.baseRefName !== defaultBranch) { - return { enable: false, reason: `base 가 ${pullRequest.baseRefName} (자동 머지는 ${defaultBranch} 만)` }; - } - if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { - return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; - } - // 방금 들어온 리뷰가 승인일 때만 건다. 사람이 예약을 끈 뒤 달린 일반 코멘트 리뷰가 예약을 - // 되살리지 않게 한다. - const lastReview = pullRequest.reviews?.nodes?.at(-1); - if (lastReview?.state !== "APPROVED") { - return { enable: false, reason: `마지막 리뷰가 승인이 아니다 (${lastReview?.state ?? "없음"})` }; - } - // 최신 판정 리컨사일 뒤의 PR 전체 판정. 변경요청이 남아 있으면 걸지 않는다. - if (pullRequest.reviewDecision !== "APPROVED") { - return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; - } - // 승인 뒤 새 커밋이 올라왔으면 그 커밋은 승인받지 않았다. - if (lastReview.commit?.oid !== pullRequest.headRefOid) { - return { enable: false, reason: "승인 뒤 새 커밋이 있다" }; - } - return { enable: true, reason: "승인됨 — 자동 머지 예약", headSha: pullRequest.headRefOid }; -} - -function main() { - const [responsePath] = process.argv.slice(2); - const defaultBranch = process.env.DEFAULT_BRANCH; - if (!responsePath || !defaultBranch) { - throw new Error("사용법: DEFAULT_BRANCH= node decide-auto-merge-on-approval.mjs "); - } - const response = JSON.parse(readFileSync(responsePath, "utf8")); - const decision = decideAutoMerge(response?.data?.repository?.pullRequest, defaultBranch); - console.log(`auto-merge: ${decision.reason}`); - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `enable=${decision.enable}\nhead_sha=${decision.headSha ?? ""}\n`); - } -} - -const isDirectExecution = process.argv[1] - && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isDirectExecution) { - try { - main(); - } catch (error) { - console.error(`::error::${error.message}`); - process.exitCode = 1; - } -} diff --git a/.github/scripts/decide-auto-merge-on-approval.test.mjs b/.github/scripts/decide-auto-merge-on-approval.test.mjs deleted file mode 100644 index 9f49faf634..0000000000 --- a/.github/scripts/decide-auto-merge-on-approval.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { decideAutoMerge } from "./decide-auto-merge-on-approval.mjs"; - -const HEAD = "0123456789abcdef0123456789abcdef01234567"; -const OLD = "fedcba9876543210fedcba9876543210fedcba98"; - -function approvedPullRequest(overrides = {}) { - return { - state: "OPEN", - isDraft: false, - baseRefName: "develop", - headRefOid: HEAD, - reviewDecision: "APPROVED", - autoMergeRequest: null, - isInMergeQueue: false, - reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }] }, - ...overrides, - }; -} - -test("develop 대상 PR 이 최신 커밋에서 승인되면 자동 머지를 예약한다", () => { - assert.deepEqual(decideAutoMerge(approvedPullRequest(), "develop"), { - enable: true, - reason: "승인됨 — 자동 머지 예약", - headSha: HEAD, - }); -}); - -test("닫힘·Draft·다른 base 는 예약하지 않는다", () => { - for (const overrides of [ - { state: "CLOSED" }, - { state: "MERGED" }, - { isDraft: true }, - { baseRefName: "main" }, - { baseRefName: "feat/123-parent" }, - ]) { - assert.equal(decideAutoMerge(approvedPullRequest(overrides), "develop").enable, false, JSON.stringify(overrides)); - } - assert.equal(decideAutoMerge(null, "develop").enable, false); -}); - -test("마지막 리뷰가 승인이 아니면 예약하지 않는다 — 끈 예약을 코멘트가 되살리지 않는다", () => { - for (const state of ["COMMENTED", "CHANGES_REQUESTED", "DISMISSED"]) { - const pullRequest = approvedPullRequest({ - reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }, { state, commit: { oid: HEAD } }] }, - }); - assert.equal(decideAutoMerge(pullRequest, "develop").enable, false, state); - } - assert.equal(decideAutoMerge(approvedPullRequest({ reviews: { nodes: [] } }), "develop").enable, false); -}); - -test("PR 전체 판정이 승인이 아니면 예약하지 않는다", () => { - for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { - assert.equal(decideAutoMerge(approvedPullRequest({ reviewDecision }), "develop").enable, false, String(reviewDecision)); - } -}); - -test("승인 뒤 새 커밋이 있으면 예약하지 않는다", () => { - const pullRequest = approvedPullRequest({ reviews: { nodes: [{ state: "APPROVED", commit: { oid: OLD } }] } }); - assert.equal(decideAutoMerge(pullRequest, "develop").enable, false); -}); - -test("이미 예약됐거나 큐에 있으면 다시 걸지 않는다", () => { - assert.equal(decideAutoMerge(approvedPullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), "develop").enable, false); - assert.equal(decideAutoMerge(approvedPullRequest({ isInMergeQueue: true }), "develop").enable, false); -}); - -test("워크플로는 리컨사일 뒤에만, 승인한 HEAD 로만 예약한다", async () => { - const workflow = await readFile( - new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), - "utf8", - ); - const job = workflow.slice(workflow.indexOf(" auto-merge:")); - assert.match(job, /needs: reconcile/); - assert.match(job, /github\.event_name == 'workflow_run'/); - assert.match(job, /decide-auto-merge-on-approval\.mjs/); - assert.match(job, /--match-head-commit "\$HEAD_SHA"/); - // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. - assert.doesNotMatch(job, /--admin/); -}); diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs new file mode 100644 index 0000000000..f527e92250 --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -0,0 +1,123 @@ +// Viewed 기반 자동 머지 대상 판정 (#2248) +// +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» +// 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 한다. +// +// viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 +// 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. +// 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PULL_REQUEST_FIELDS = ` + number isDraft headRefOid reviewDecision isInMergeQueue changedFiles + autoMergeRequest { enabledAt } + files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } +`; + +/** + * @param {object} pullRequest GraphQL PullRequest 노드 + * @param {Array<{ path: string, viewerViewedState: string }>} files PR 의 전체 변경 파일 + * @returns {{ enable: boolean, reason: string }} + */ +export function decideViewedAutoMerge(pullRequest, files) { + if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; + if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { + return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; + } + if (pullRequest.reviewDecision !== "APPROVED") { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + } + // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. + if (files.length === 0 || files.length !== pullRequest.changedFiles) { + return { enable: false, reason: `파일 ${files.length}/${pullRequest.changedFiles} 만 읽었다` }; + } + const unviewed = files.filter((file) => file.viewerViewedState !== "VIEWED"); + if (unviewed.length > 0) { + return { enable: false, reason: `Viewed 안 된 파일 ${unviewed.length}/${files.length}` }; + } + return { enable: true, reason: `모든 파일 Viewed·승인 — 자동 머지 예약` }; +} + +async function graphql(token, apiUrl, query, variables) { + const response = await fetch(`${apiUrl}/graphql`, { + method: "POST", + headers: { authorization: `bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + const payload = await response.json(); + if (!response.ok || payload.errors) { + throw new Error(`GraphQL 실패 (${response.status}): ${JSON.stringify(payload.errors ?? payload)}`); + } + return payload.data; +} + +async function readAllFiles(request, owner, name, pullRequest) { + const files = [...pullRequest.files.nodes]; + let { hasNextPage, endCursor } = pullRequest.files.pageInfo; + while (hasNextPage) { + const data = await request( + `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } + } + } + }`, + { owner, name, number: pullRequest.number, after: endCursor }, + ); + const page = data.repository.pullRequest.files; + files.push(...page.nodes); + ({ hasNextPage, endCursor } = page.pageInfo); + } + return files; +} + +async function main() { + const token = process.env.VIEWED_TOKEN; + const viewedBy = process.env.VIEWED_BY; + const baseBranch = process.env.BASE_BRANCH; + const [owner, name] = (process.env.GITHUB_REPOSITORY ?? "").split("/"); + if (!token || !viewedBy || !baseBranch || !owner || !name) { + throw new Error("VIEWED_TOKEN·VIEWED_BY·BASE_BRANCH·GITHUB_REPOSITORY 가 필요합니다."); + } + const request = (query, variables) => + graphql(token, process.env.GITHUB_API_URL ?? "https://api.github.com", query, variables); + + const data = await request( + `query($owner: String!, $name: String!, $base: String!) { + viewer { login } + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, baseRefName: $base, first: 50) { nodes { ${PULL_REQUEST_FIELDS} } } + } + }`, + { owner, name, base: baseBranch }, + ); + if (data.viewer.login.toLowerCase() !== viewedBy.toLowerCase()) { + throw new Error(`VIEWED_TOKEN 주인이 ${data.viewer.login} 입니다 — ${viewedBy} 의 토큰이어야 합니다.`); + } + + const targets = []; + for (const pullRequest of data.repository.pullRequests.nodes) { + const files = await readAllFiles(request, owner, name, pullRequest); + const decision = decideViewedAutoMerge(pullRequest, files); + console.log(`#${pullRequest.number}: ${decision.reason}`); + if (decision.enable) targets.push({ number: pullRequest.number, headSha: pullRequest.headRefOid }); + } + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `targets=${JSON.stringify(targets)}\n`); + } +} + +const isDirectExecution = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + try { + await main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs new file mode 100644 index 0000000000..1ca002167e --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { decideViewedAutoMerge } from "./find-viewed-auto-merge-targets.mjs"; + +const viewed = (path) => ({ path, viewerViewedState: "VIEWED" }); + +function pullRequest(overrides = {}) { + return { + number: 1, + isDraft: false, + headRefOid: "0123456789abcdef0123456789abcdef01234567", + reviewDecision: "APPROVED", + isInMergeQueue: false, + autoMergeRequest: null, + changedFiles: 2, + ...overrides, + }; +} + +test("모든 파일이 Viewed 이고 승인 상태면 예약한다", () => { + assert.equal(decideViewedAutoMerge(pullRequest(), [viewed("a.kt"), viewed("b.kt")]).enable, true); +}); + +test("하나라도 Viewed 가 아니면 예약하지 않는다 — 체크 뒤 바뀐 파일(DISMISSED) 포함", () => { + for (const state of ["UNVIEWED", "DISMISSED"]) { + const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: state }]; + assert.equal(decideViewedAutoMerge(pullRequest(), files).enable, false, state); + } +}); + +test("승인 상태가 아니면 Viewed 를 다 해도 예약하지 않는다", () => { + for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { + const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); + assert.equal(decision.enable, false, String(reviewDecision)); + } +}); + +test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않는다", () => { + assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 0 }), []).enable, false); +}); + +test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => { + const files = [viewed("a.kt"), viewed("b.kt")]; + assert.equal(decideViewedAutoMerge(pullRequest({ isDraft: true }), files).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), files).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); +}); + +test("워크플로는 본인 토큰으로 읽고 GITHUB_TOKEN 으로 승인한 HEAD 만 예약한다", async () => { + const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); + assert.match(workflow, /^permissions: \{\}$/m); + assert.match(workflow, /VIEWED_TOKEN: \$\{\{ secrets\.VIEWED_TOKEN \}\}/); + assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(workflow, /persist-credentials: false/); + assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match(workflow, /--match-head-commit "\$head_sha"/); + // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. + assert.doesNotMatch(workflow, /--admin/); + // PR 코드를 체크아웃하지 않는다. + assert.doesNotMatch(workflow, /github\.event\.pull_request\.head|refs\/pull/); +}); diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index dfc61e24f5..270dfc1ece 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -44,62 +44,3 @@ jobs: TARGET_PULL_NUMBER: >- ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} run: node .github/scripts/reconcile-latest-review-decision.mjs - - # 승인 직후 «Merge when ready» 를 대신 건다. 머지 자체는 GitHub 가 승인 수·required check· - # merge queue 를 모두 통과한 뒤에 하므로 CI 가 빨갛거나 변경요청이 남으면 머지되지 않는다. - # 리컨사일이 낡은 변경요청을 걷은 뒤의 판정을 보려고 needs 로 잇는다. 리뷰어의 파일 Viewed - # 체크는 토큰 주인 본인 것만 조회되고 이벤트도 없어 조건에 넣지 못한다(판정 스크립트 머리말). - auto-merge: - name: Enable auto-merge on approval - needs: reconcile - if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - pull-requests: write - steps: - - name: Clone decision script from the default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - sparse-checkout: .github/scripts - - - name: Decide - id: decide - env: - GH_TOKEN: ${{ github.token }} - OWNER: ${{ github.repository_owner }} - NAME: ${{ github.event.repository.name }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - QUERY: | - query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - state isDraft baseRefName headRefOid reviewDecision isInMergeQueue - autoMergeRequest { enabledAt } - reviews(last: 1) { nodes { state commit { oid } } } - } - } - } - run: | - set -euo pipefail - gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query="$QUERY" \ - > "$RUNNER_TEMP/pull-request.json" - node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" - - - name: Enable auto-merge - if: steps.decide.outputs.enable == 'true' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - HEAD_SHA: ${{ steps.decide.outputs.head_sha }} - run: | - # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. - # 스택 PR 처럼 GitHub 가 거부하는 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. - if ! gh pr merge "$PR_NUMBER" --match-head-commit "$HEAD_SHA"; then - echo "::warning::#$PR_NUMBER 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" - fi diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml new file mode 100644 index 0000000000..362d0d947b --- /dev/null +++ b/.github/workflows/viewed-auto-merge.yml @@ -0,0 +1,75 @@ +# Viewed 기반 자동 머지 예약 (#2248) +# +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. +# 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 +# 변경요청이 남으면 머지되지 않는다. 예약을 멈추려면 파일 하나의 Viewed 를 풀면 된다(다음 실행이 다시 +# 걸지 않는다). 이미 걸린 예약은 PR 화면에서 끈다. +# +# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 +# 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN +# 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. 예약 쓰기는 GITHUB_TOKEN 이 한다. +name: Viewed Auto Merge + +on: + schedule: + - cron: '*/10 * * * *' + workflow_run: + workflows: [Latest Review Decision Reconcile] + types: [completed] + workflow_dispatch: + +concurrency: + group: viewed-auto-merge-${{ github.repository }} + cancel-in-progress: false + +permissions: {} + +jobs: + enable: + name: Enable auto-merge for fully viewed PRs + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + steps: + - name: Clone script from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Find fully viewed approved PRs + id: find + env: + VIEWED_TOKEN: ${{ secrets.VIEWED_TOKEN }} + VIEWED_BY: 1hyok + BASE_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_API_URL: ${{ github.api_url }} + run: | + if [ -z "$VIEWED_TOKEN" ]; then + echo "::notice::VIEWED_TOKEN 시크릿이 없어 건너뜁니다" + echo 'targets=[]' >> "$GITHUB_OUTPUT" + exit 0 + fi + node .github/scripts/find-viewed-auto-merge-targets.mjs + + - name: Enable auto-merge + if: steps.find.outputs.targets != '[]' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TARGETS: ${{ steps.find.outputs.targets }} + run: | + # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. + # 판정 뒤 새 커밋이 올라왔으면 --match-head-commit 이 거부한다. 스택 PR 처럼 GitHub 가 거부하는 + # 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. + jq -c '.[]' <<< "$TARGETS" | while read -r target; do + number=$(jq -r '.number' <<< "$target") + head_sha=$(jq -r '.headSha' <<< "$target") + if ! gh pr merge "$number" --match-head-commit "$head_sha"; then + echo "::warning::#$number 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" + fi + done diff --git a/README.md b/README.md index 7300ec3f48..d308fb7f67 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 - PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. - Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. -- `develop` 대상 PR은 최신 커밋에 승인이 달리면 [리뷰 판정 리컨사일 workflow](.github/workflows/latest-review-decision-reconcile.yml)가 자동 머지(Merge when ready)를 예약한다. 필수 검사가 통과해야 merge queue로 들어가고, 원치 않으면 PR 화면에서 예약을 끄면 된다. 리뷰어의 파일 Viewed 체크는 본인 토큰으로만 조회되고 이벤트도 없어 조건에 넣지 못하므로, 승인은 모든 파일을 확인한 뒤에 한다. +- `develop` 대상 PR은 승인 상태에서 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. - [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. - `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. From 603cd761b2e6b2e9ab9e93b5acd5c9663e68b394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:55:40 +0000 Subject: [PATCH 063/116] =?UTF-8?q?chore(ci):=20merge=20queue=20=EB=B0=A9?= =?UTF-8?q?=EC=B6=9C=EC=9D=B4=20GMD=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=BF=90=EC=9D=B4=EB=A9=B4=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20head=20=EB=8B=B9=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=ED=95=9C=EB=8B=A4=20(#2250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/handle-merge-queue-dequeue.mjs | 64 ++++++++++++++++--- .../handle-merge-queue-dequeue.test.mjs | 54 ++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index b1498e601d..706d1c04e9 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -10,6 +10,11 @@ // 한 번만 재투입한다. 0902 에 감시 루프가 90초마다 맹목 재투입해 30분을 헛돈 사고(#1638·#1639)가 // «한 번까지» 규칙의 근거다. 횟수는 이 스크립트가 남기는 마커 코멘트로 센다. // +// 예외는 증명된 인프라 실패다(#2250). GMD 분류기(classify-android-managed-device-failure.mjs)가 테스트가 돌기 전에 +// 죽었다고 판정하면 그 run 에 재시도 마커 아티팩트를 남긴다. 실패 job 이 전부 그런 job 이면 PR 코드가 실행되기 +// 전에 끝난 것이므로 조용한 방출과 같이 같은 head 당 한 번 재투입한다. 머지 큐는 실패한 필수 체크로 바로 방출하므로 +// job 재실행(android-managed-device-retry.yml)으로는 복구되지 않고 재투입만이 수단이다. +// // 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 // 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 // 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. @@ -31,6 +36,11 @@ export const REQUEUE_MARKER_PREFIX = "`; } @@ -75,14 +105,16 @@ export function countRequeuesForHead(comments, headSha) { /** * 판정표. - * 1. 실패 job 이 있다 → comment-failure (재투입 없음) + * 1. 실패 job 이 있고 그중 하나라도 인프라로 증명되지 않았다 → comment-failure (재투입 없음) * 2. reason 이 MANUAL·ALREADY_MERGED·MERGE → none - * 3. 같은 head 에서 이미 재투입했다 → comment-give-up - * 4. 그 밖 → requeue + * 3. 실패 job 이 전부 증명된 인프라 실패다 → 같은 head 첫 번째면 requeue, 이미 재투입했으면 comment-failure + * 4. 같은 head 에서 이미 재투입했다 → comment-give-up + * 5. 그 밖 → requeue */ export function decide({ reason, failedJobs, requeueCount }) { - if (failedJobs.length > 0) return "comment-failure"; + if (failedJobs.length > 0 && !onlyInfrastructureFailures(failedJobs)) return "comment-failure"; if (NO_ACTION_REASONS.has(String(reason ?? "").toUpperCase())) return "none"; + if (failedJobs.length > 0) return requeueCount >= 1 ? "comment-failure" : "requeue"; if (requeueCount >= 1) return "comment-give-up"; return "requeue"; } @@ -98,7 +130,7 @@ export function manualEnqueueCommand({ repository, number, stack = false }) { } export function renderFailureComment({ reason, headSha, failedJobs, repository, number, stack = false }) { - const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); + const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}${job.infrastructure ? " (인프라 실패로 분류됨)" : ""}`); return [ "### merge queue 방출 — merge group CI 실패", "", @@ -111,12 +143,16 @@ export function renderFailureComment({ reason, headSha, failedJobs, repository, ].join("\n"); } -export function renderRequeueComment({ reason, headSha }) { +export function renderRequeueComment({ reason, headSha, failedJobs = [] }) { + const why = failedJobs.length > 0 + ? "merge group 실패 job 이 전부 테스트 전 인프라 실패로 분류됨 → 한 번 재투입했다." + : "merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다."; return [ requeueMarker(headSha), "### merge queue 방출 — 재투입했다", "", - `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다.`, + `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · ${why}`, + ...failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`), "같은 head 에서 또 방출되면 재투입하지 않고 여기에 남긴다.", ].join("\n"); } @@ -223,11 +259,19 @@ export async function fetchQueueFailedJobs(api, repository, { baseRef, number, s .filter((run) => !since || typeof run.created_at !== "string" || run.created_at >= since) .slice(0, QUEUE_RUNS_TO_INSPECT); const jobsByRunId = new Map(); + const artifactsByRunId = new Map(); for (const run of runs) { const jobs = await api(`/repos/${repository}/actions/runs/${run.id}/jobs?per_page=100`); jobsByRunId.set(run.id, jobs.jobs ?? []); + // 마커를 남길 수 있는 run 만 아티팩트를 본다 — 실패한 GMD job 이 없으면 볼 필요가 없다. + const hasFailedManagedDeviceJob = (jobs.jobs ?? []).some((job) => + FAILED_JOB_CONCLUSIONS.has(job.conclusion) && [...MANAGED_DEVICE_RETRY_JOBS.values()].includes(job.name)); + if (hasFailedManagedDeviceJob) { + const artifacts = await api(`/repos/${repository}/actions/runs/${run.id}/artifacts?per_page=100`); + artifactsByRunId.set(run.id, artifacts.artifacts ?? []); + } } - return collectFailedJobs({ runs, jobsByRunId, baseRef, number, since }); + return collectFailedJobs({ runs, jobsByRunId, artifactsByRunId, baseRef, number, since }); } export async function fetchComments(api, repository, number) { @@ -327,7 +371,7 @@ export async function handleDequeue({ await postComment(api, repository, number, renderStackUpperComment({ reason, headSha, repository, number })); } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. - await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); + await postComment(api, repository, number, renderRequeueComment({ reason, headSha, failedJobs })); if (stack) { const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); if (enqueueResult.unresolved) { diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index d498d0bacd..670a79d9aa 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,6 +7,7 @@ import { collectFailedJobs, countRequeuesForHead, decide, + provenInfrastructureJobNames, enqueueStacked, handleDequeue, isStackBottom, @@ -47,6 +48,24 @@ test("실패 job 없는 방출은 같은 head 에서 한 번만 재투입하고, assert.equal(decide({ reason: "ROLL_BACK", failedJobs: [], requeueCount: 3 }), "comment-give-up"); }); +test("실패 job 이 전부 증명된 인프라 실패면 같은 head 에서 한 번 재투입하고, 두 번째는 링크만 남긴다", () => { + const infra = [{ jobName: "Pixel 2 API 30 androidTest", conclusion: "failure", infrastructure: true }]; + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: infra, requeueCount: 0 }), "requeue"); + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: infra, requeueCount: 1 }), "comment-failure"); + assert.equal(decide({ reason: "MANUAL", failedJobs: infra, requeueCount: 0 }), "none"); + const mixed = [...infra, { jobName: "Run Unit Tests", conclusion: "failure", infrastructure: false }]; + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: mixed, requeueCount: 0 }), "comment-failure"); +}); + +test("재시도 마커는 같은 run·attempt 이름만 인정한다 — 다른 run·만료·모르는 device 는 증명이 아니다", () => { + const run = { id: 37178469871, run_attempt: 1 }; + assert.deepEqual([...provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-1" }])], ["Pixel 2 API 30 androidTest"]); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-2" }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-1-1" }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-1", expired: true }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api26-37178469871-1" }]).size, 0); +}); + // ---- 실패 job 수집 ---- test("이 PR 의 큐 브랜치 run 에서 failure·timed_out job 만 모은다 — 다른 PR 의 run, cancelled job 은 뺀다", () => { @@ -165,6 +184,7 @@ function fakeApi({ liveAfter = live, runs = [], jobs = {}, + artifacts = {}, comments = [], mergeAsync = { status: "pending", @@ -199,6 +219,8 @@ function fakeApi({ return respond(mergeAsyncPolls[Math.min(pollReads++, mergeAsyncPolls.length - 1)]); } if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; + const artifactsMatch = /\/actions\/runs\/(\d+)\/artifacts/.exec(apiPath); + if (artifactsMatch) return { artifacts: artifacts[artifactsMatch[1]] ?? [] }; const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; if (apiPath.includes("/comments") && (options.method ?? "GET") === "GET") return comments; @@ -524,6 +546,38 @@ test("CI 실패: 실패 job 링크 코멘트만, 재투입 없음", async () => assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); }); +test("GMD 인프라 실패만으로 방출되면 마커 코멘트 뒤 재투입한다 — 마커 없는 같은 job 은 실패 코멘트만", async () => { + const runs = [{ id: 9, run_attempt: 1, name: "Android Managed Device Test", html_url: "https://x/runs/9", head_branch: queueBranchPrefix("develop", 1509) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 9: [ + { name: "Pixel 2 API 30 androidTest", conclusion: "failure", html_url: "https://x/jobs/91" }, + { name: "Pixel 2 API 34 accessibility smoke", conclusion: "success", html_url: "https://x/jobs/92" }, + ] }; + const proven = fakeApi({ live: openLive, runs, jobs, artifacts: { 9: [{ name: "android-managed-device-retry-api30-9-1" }] } }); + const result = await handleDequeue({ api: proven, repository: REPO, number: 1509, reason: "CI_FAILURE", logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(enqueueMutations(proven).length, 1); + const comment = commentPosts(proven)[0].body.body; + assert.ok(comment.startsWith(requeueMarker(HEAD))); + assert.match(comment, /인프라 실패로 분류됨/); + assert.match(comment, /https:\/\/x\/jobs\/91/); + const writes = proven.calls.filter((call) => call.method === "POST"); + assert.ok(writes.findIndex((call) => call.apiPath.endsWith("/comments")) < writes.findIndex((call) => call.body?.query?.includes("enqueuePullRequest"))); + + const unproven = fakeApi({ live: openLive, runs, jobs }); + const failed = await handleDequeue({ api: unproven, repository: REPO, number: 1509, reason: "CI_FAILURE", logger: silent }); + assert.equal(failed.action, "comment-failure"); + assert.equal(enqueueMutations(unproven).length, 0); + + const second = fakeApi({ + live: openLive, runs, jobs, + artifacts: { 9: [{ name: "android-managed-device-retry-api30-9-1" }] }, + comments: [{ body: renderRequeueComment({ reason: "CI_FAILURE", headSha: HEAD }) }], + }); + const again = await handleDequeue({ api: second, repository: REPO, number: 1509, reason: "CI_FAILURE", logger: silent }); + assert.equal(again.action, "comment-failure"); + assert.equal(enqueueMutations(second).length, 0); +}); + test("dry-run 은 판정만 하고 아무것도 쓰지 않는다 — 스택 PR 도 같다", async () => { for (const live of [openLive, stackedLive, stackUpperLive]) { const api = fakeApi({ live }); From 15a48cdee7c597158d21f7a6e46f57d31e52d5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:59:39 +0000 Subject: [PATCH 064/116] =?UTF-8?q?fix(ci):=20API30=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20androidTest=20=EB=A0=88=EC=9D=B8=EC=97=90=EB=8F=84=20APK=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=95=EC=9A=A9=204Gi=20=ED=9E=99=EC=9D=84?= =?UTF-8?q?=20=EC=A4=80=EB=8B=A4=20(#2250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-managed-device.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index aeea5b05c7..333d6fd48d 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -647,8 +647,11 @@ jobs: local test_arguments=() jvm_arguments=() # #1824 merge-group API34: cold APK packaging exhausted the 2Gi heap # in ApkFlinger. The 15Gi runner still had 7Gi available, so reserve - # 4Gi for this lane only and preserve the existing UTF-8 JVM flag. - if [[ "$DEVICE_TASK" == "pixel2Api34DebugAndroidTest" ]]; then + # 4Gi and preserve the existing UTF-8 JVM flag. + # #2250 merge-group API30 (#1837 큐): 같은 ApkFlinger OOM 이 :app:packageDebug 에서 + # 시스템 이미지 설치와 겹쳐 났다. 전체 androidTest 레인도 같은 4Gi 를 쓴다. + if [[ "$DEVICE_TASK" == "pixel2Api34DebugAndroidTest" || + "$DEVICE_TASK" == "pixel2Api30DebugAndroidTest" ]]; then jvm_arguments+=("-Dorg.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8") fi if [[ -n "$selectors" ]]; then From 4f9d86aeb05f559adfb84e2e002c97fbedc78d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 11 Sep 2026 16:51:53 +0900 Subject: [PATCH 065/116] =?UTF-8?q?refactor(afternote):=20SaveAfternoteUse?= =?UTF-8?q?Case=20=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=9C=EB=8B=A4=20(#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/editor/SaveAfternoteUseCase.kt | 48 +++++ .../editor/SaveAfternoteUseCaseTest.kt | 182 ++++++++++++++++++ .../editor/AfternoteEditorViewModel.kt | 28 +-- .../AfternoteAuthorExtendedTest.kt | 2 + .../AfternoteAuthorImplementedCoverageTest.kt | 2 + .../presentation/AfternoteAuthorTest.kt | 2 + .../AfternoteEditorMemorialMediaRemoveTest.kt | 2 + .../AfternoteEditorMissingBaselineTest.kt | 2 + ...ternoteEditorMultiReceiverSelectionTest.kt | 2 + .../AfternoteEditorPrefillFailureTest.kt | 3 + .../AfternoteEditorProcessDeathPrefillTest.kt | 2 + ...rnoteEditorProcessingMethodDefaultsTest.kt | 2 + ...noteEditorReceiverSelectionRecoveryTest.kt | 2 + .../AfternoteEditorReceiverSnapshotTest.kt | 2 + ...fternoteEditorServerMediaDeleteSaveTest.kt | 2 + .../AfternoteEditorThumbnailRetryTest.kt | 2 + .../AfternoteEditorValidationEventTest.kt | 2 + 17 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt create mode 100644 feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt new file mode 100644 index 0000000000..c937b3a6ba --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCase.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import javax.inject.Inject + +/** + * 완성된 [SaveAfternoteCommand] 를 해석하는 **단일 경계**. + * + * 도입 근거는 「비즈니스 로직이 많다」가 아니라 *해석의 소재지* 다. [SaveAfternoteCommand] 는 도메인이 + * 소유한 닫힌(sealed) 명령이고, 그 5갈래(Create 4종 · Update)를 [AfternoteRepository] 의 어느 메서드로 + * 보낼지는 명령 정의와 짝을 이루는 도메인 지식이다. 명령이 도메인에 있는 한 해석도 도메인에 있어야 + * 명령에 갈래가 늘 때 고칠 곳이 한 곳으로 닫힌다 — 화면이 몇 개든, 호출부가 지금 하나든 상관없다. + * + * 과거 `#246` 은 이 UseCase 를 «Repository 프록시» 로 보고 제거했지만, 같은 `when` 은 사라지지 않고 + * `AfternoteEditorViewModel` 로 옮겨갔을 뿐이었다. 즉 제거된 것은 중복이 아니라 경계였다 (`#1694`). + * + * 여기서 하지 않는 일 — 입력 검증, UI 폼 매핑, 미디어 입력 선택, 저장 상태·문구·네비게이션. + * 그 책임들은 명령을 *만드는* 쪽(presentation)에 그대로 남는다. + */ +class SaveAfternoteUseCase + @Inject + constructor( + private val afternoteRepository: AfternoteRepository, + ) { + /** + * Repository 의 [Result] 를 감싸거나 다시 던지지 않고 그대로 돌려준다 — 저장 실패의 해석은 + * 호출부(에러 문구·재시도)의 몫이고, `runCatching` 을 두르지 않아 코루틴 취소도 삼키지 않는다. + * + * @return 생성·수정된 애프터노트 id. + */ + suspend operator fun invoke(command: SaveAfternoteCommand): Result = + when (command) { + is SaveAfternoteCommand.Create -> { + when (val input = command.input) { + is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) + is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) + is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) + is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) + } + } + + is SaveAfternoteCommand.Update -> { + afternoteRepository.update(command.id, command.payload) + } + } + } diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt new file mode 100644 index 0000000000..88bf8c8c20 --- /dev/null +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt @@ -0,0 +1,182 @@ +package com.afternote.feature.afternote.domain.usecase.editor + +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload +import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput +import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload +import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload +import com.afternote.feature.afternote.domain.model.author.FieldPatch +import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload +import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [SaveAfternoteUseCase] 가 소유하는 것 — [SaveAfternoteCommand] 5갈래의 해석 경계. + * + * 고정하는 계약: + * 1. Create 4종(Social·Business·Gallery·Memorial)이 각각 *정확히 하나의* Repository 메서드로만 간다. + * (기록 목록을 전부 세어, 목표 외 메서드가 0회임을 같이 단언한다 — 라우팅이 옆칸으로 새는 회귀를 잡는다) + * 2. payload 를 가공하지 않고 그대로 넘긴다. Update 는 id 와 payload 를 둘 다 바꾸지 않는다. + * 3. Repository 의 성공·실패 [Result] 를 감싸지 않고 그대로 돌려준다. + * 4. `runCatching` 을 두르지 않아 코루틴 취소를 [Result.failure] 로 삼키지 않는다. + */ +class SaveAfternoteUseCaseTest { + private val accountPayload = + CreateAccountPayload( + title = "인스타그램", + processingMethods = listOf("계정 삭제"), + ) + + private val galleryPayload = + CreateGalleryPayload( + title = "사진첩", + processingMethods = listOf("전달"), + ) + + private val memorialPayload = + CreateMemorialPayload( + title = "장례식", + memorial = + MemorialWritePayload( + memorialPhotoUrl = null, + songs = emptyList(), + memorialVideo = null, + ), + ) + + @Test + fun `Social 생성은 createSocial 만 호출하고 payload 를 그대로 넘긴다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.socialPayloads) + repository.assertOnlyCalled(SOCIAL) + } + + @Test + fun `Business 생성은 createBusiness 만 호출한다 - 같은 스키마의 createSocial 로 새지 않는다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Business(accountPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(accountPayload), repository.businessPayloads) + repository.assertOnlyCalled(BUSINESS) + } + + @Test + fun `Gallery 생성은 createGallery 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(galleryPayload), repository.galleryPayloads) + repository.assertOnlyCalled(GALLERY) + } + + @Test + fun `Memorial 생성은 createMemorial 만 호출한다`() { + val repository = FakeAfternoteRepository() + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + + assertTrue(result.isSuccess) + assertEquals(listOf(memorialPayload), repository.memorialPayloads) + repository.assertOnlyCalled(MEMORIAL) + } + + @Test + fun `Update 는 id 와 payload 를 바꾸지 않고 update 로만 보낸다`() { + val repository = FakeAfternoteRepository() + val payload = + AfternoteUpdatePayload( + type = AfternoteType.MEMORIAL, + title = "바뀐 제목", + memorial = + MemorialPatchPayload( + memorialPhotoUrl = FieldPatch.Unchanged, + songs = emptyList(), + memorialVideo = FieldPatch.Set(null), + ), + isDraft = false, + ) + + // 기본 update 는 대상이 없으면 실패하므로 성공 Result 를 열어 라우팅만 본다. + repository.onUpdate = { _, _ -> Result.success(42L) } + val result = repository.save(SaveAfternoteCommand.Update(id = 42L, payload = payload)) + + assertEquals(42L, result.getOrNull()) + assertEquals(listOf(42L to payload), repository.updateCalls) + assertSame(payload, repository.updateCalls.single().second) + repository.assertOnlyCalled(UPDATE) + } + + @Test + fun `Repository 성공 id 를 그대로 돌려준다`() { + val repository = FakeAfternoteRepository().apply { onCreateGallery = { Result.success(7L) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Gallery(galleryPayload))) + + assertEquals(7L, result.getOrNull()) + } + + @Test + fun `Repository 실패는 감싸지 않고 같은 예외로 돌려준다`() { + val failure = IllegalStateException("저장 실패") + val repository = FakeAfternoteRepository().apply { onCreateSocial = { Result.failure(failure) } } + + val result = repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Social(accountPayload))) + + assertTrue(result.isFailure) + assertSame(failure, result.exceptionOrNull()) + } + + @Test + fun `취소는 실패 Result 로 삼키지 않고 그대로 전파한다`() { + val repository = + FakeAfternoteRepository().apply { + onCreateMemorial = { throw CancellationException("저장 중 화면 이탈") } + } + + // 삼켰다면 Result.failure 로 정상 반환돼 assertThrows 가 실패한다. + assertThrows(CancellationException::class.java) { + repository.save(SaveAfternoteCommand.Create(CreateAfternoteInput.Memorial(memorialPayload))) + } + } + + private fun FakeAfternoteRepository.save(command: SaveAfternoteCommand): Result = + runBlocking { SaveAfternoteUseCase(this@save)(command) } + + /** [only] 로 지목한 저장 메서드만 1회 호출되고 나머지 4개는 0회임을 단언한다. */ + private fun FakeAfternoteRepository.assertOnlyCalled(only: String) { + val calls = + mapOf( + SOCIAL to socialPayloads.size, + BUSINESS to businessPayloads.size, + GALLERY to galleryPayloads.size, + MEMORIAL to memorialPayloads.size, + UPDATE to updateCalls.size, + ) + assertEquals(calls.keys.associateWith { if (it == only) 1 else 0 }, calls) + } + + private companion object { + const val SOCIAL = "createSocial" + const val BUSINESS = "createBusiness" + const val GALLERY = "createGallery" + const val MEMORIAL = "createMemorial" + const val UPDATE = "update" + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 99ca625c48..be83517e26 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -9,12 +9,12 @@ import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.error.AfternoteFailure -import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -180,6 +180,8 @@ class AfternoteEditorViewModel private val afternoteRepository: AfternoteRepository, private val memorialThumbnailUploadRepository: MemorialThumbnailUploadRepository, private val resolveMemorialMediaForSave: ResolveMemorialMediaForSaveUseCase, + // 이 클래스에 동명의 `saveAfternote(...)` 진입점이 이미 있어 `UseCase` 접미사를 남긴다. + private val saveAfternoteUseCase: SaveAfternoteUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { /** 진행 중인 prefill 조회 — 재시도가 이전 조회를 자르기 위한 핸들. */ @@ -523,7 +525,7 @@ class AfternoteEditorViewModel updateBaseline = updateBaseline, ).fold( onSuccess = { command -> - executeSaveCommand(command).fold( + saveAfternoteUseCase(command).fold( onSuccess = { id -> internalState.update { it.copy( @@ -541,28 +543,6 @@ class AfternoteEditorViewModel } } - /** - * [SaveAfternoteCommand] 분기에 따라 [AfternoteRepository] 의 적합한 메서드를 직접 호출한다. - * - * 과거에는 별도 `SaveAfternoteUseCase` 로 분리돼 있었으나, 단일 Repository 내 메서드 라우팅 - * 외에 비즈니스 로직이 없어 *약한 UseCase* (`#246`) 로 판단해 ViewModel 로 흡수. - */ - private suspend fun executeSaveCommand(command: SaveAfternoteCommand): Result = - when (command) { - is SaveAfternoteCommand.Create -> { - when (val input = command.input) { - is CreateAfternoteInput.Social -> afternoteRepository.createSocial(input.payload) - is CreateAfternoteInput.Business -> afternoteRepository.createBusiness(input.payload) - is CreateAfternoteInput.Gallery -> afternoteRepository.createGallery(input.payload) - is CreateAfternoteInput.Memorial -> afternoteRepository.createMemorial(input.payload) - } - } - - is SaveAfternoteCommand.Update -> { - afternoteRepository.update(command.id, command.payload) - } - } - // 영정 사진: 새로 고른 로컬 픽 우선 → 없으면 기존 원격 → 둘 다 없으면 없음. private fun photoMediaInput( picked: String?, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 38333c3803..aaff21d93b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -44,6 +44,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.detail.AfternoteDetailDeleteResult import com.afternote.feature.afternote.presentation.detail.AfternoteDetailUiState @@ -546,6 +547,7 @@ private fun editorViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt index 446d5776bc..30df4dad97 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorImplementedCoverageTest.kt @@ -21,6 +21,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.memorial.Song @@ -317,5 +318,6 @@ private fun implementedCoverageViewModel( ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt index bb19c9fe64..4c49b8b7c5 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorTest.kt @@ -13,6 +13,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel import com.afternote.feature.afternote.presentation.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.editor.message.EditorMessageTextBlock @@ -166,6 +167,7 @@ class AfternoteAuthorTest { ) }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt index 1638219098..b90599027b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMemorialMediaRemoveTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.memorial.Song import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.editor.model.EditorFormPrefill @@ -171,6 +172,7 @@ class AfternoteEditorMemorialMediaRemoveTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt index 8e25f01c09..5d3e9809cd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMissingBaselineTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError @@ -151,6 +152,7 @@ class AfternoteEditorMissingBaselineTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt index 1ae337e6d6..0e74651e1b 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorMultiReceiverSelectionTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -176,6 +177,7 @@ class AfternoteEditorMultiReceiverSelectionTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt index 3c294434b3..511229516f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorPrefillFailureTest.kt @@ -14,6 +14,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload @@ -254,6 +255,7 @@ class AfternoteEditorPrefillFailureTest { if (input == MediaInput.None) Result.success(null) else error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = RecordingErrorReporter(), ) backgroundScope.launch { viewModel.uiState.collect {} } @@ -308,6 +310,7 @@ class AfternoteEditorPrefillFailureTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = errorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 2a1d3b6dbd..0bd912f5b4 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -115,6 +116,7 @@ class AfternoteEditorProcessDeathPrefillTest { ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, + saveAfternoteUseCase = SaveAfternoteUseCase(afternoteRepository), memorialThumbnailUploadRepository = MemorialThumbnailUploadRepository { error("썸네일 업로드가 호출되면 안 됩니다") }, resolveMemorialMediaForSave = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt index 938f3d39f1..0aed91e3df 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessingMethodDefaultsTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editorFlowRoute import kotlinx.coroutines.Dispatchers @@ -102,6 +103,7 @@ class AfternoteEditorProcessingMethodDefaultsTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt index 8d3f2426c8..2a9ca38857 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSelectionRecoveryTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -126,6 +127,7 @@ class AfternoteEditorReceiverSelectionRecoveryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(unusedProxy()), errorReporter = NoopErrorReporter, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt index b914d0c9e5..e73bc6f408 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorReceiverSnapshotTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.MemorialVideoAttachment import com.afternote.feature.afternote.presentation.editorFlowRoute import org.junit.Assert.assertEquals @@ -106,6 +107,7 @@ class AfternoteEditorReceiverSnapshotTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt index 7a7bc2adc0..d33b57e7f4 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorServerMediaDeleteSaveTest.kt @@ -12,6 +12,7 @@ import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUpl import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter import com.afternote.feature.afternote.presentation.afternoteAuthorUserReceiverRepository import com.afternote.feature.afternote.presentation.afternoteEditorSavedStateHandle @@ -207,6 +208,7 @@ class AfternoteEditorServerMediaDeleteSaveTest { } }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repository), errorReporter = NoopAuthorErrorReporter, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt index 1ec547faad..c9aa9d5fa2 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorThumbnailRetryTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers @@ -160,6 +161,7 @@ class AfternoteEditorThumbnailRetryTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 업로드가 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt index a43a15458d..7057dd9a4a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorValidationEventTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.afternote.domain.repository.author.AfternoteReposit import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase +import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.editor.state.AfternoteEditorError import com.afternote.feature.afternote.presentation.editor.state.AfternoteValidationError @@ -113,6 +114,7 @@ class AfternoteEditorValidationEventTest { ResolveMemorialMediaForSaveUseCase( MemorialMediaUploadRepository { _, _ -> error("미디어 저장이 호출되면 안 됩니다") }, ), + saveAfternoteUseCase = SaveAfternoteUseCase(repositoryProxy()), errorReporter = repositoryProxy(), ) From f31a2dcb17124c14d1372a19a1a6f992c15307e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 07:52:24 +0000 Subject: [PATCH 066/116] =?UTF-8?q?ci(review):=20Viewed=20=EA=B0=80=20?= =?UTF-8?q?=ED=92=80=EB=A6=B0=20PR=20=EC=9D=98=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A8=B8=EC=A7=80=20=EC=98=88=EC=95=BD=EA=B3=BC=20merge=20queu?= =?UTF-8?q?e=20=EB=93=B1=EB=A1=9D=EC=9D=84=20=ED=95=B4=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../find-viewed-auto-merge-targets.mjs | 47 +++++++++++++- .../find-viewed-auto-merge-targets.test.mjs | 65 ++++++++++++++++++- .github/workflows/viewed-auto-merge.yml | 41 ++++++++++-- 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index f527e92250..e605e60c47 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -7,11 +7,15 @@ // viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 // 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. // 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. +// +// 예약을 건 뒤 새 push 로 Viewed 가 풀려도 GitHub 는 예약·큐 등록을 그대로 두고, develop 룰셋은 push 로 승인을 +// 지우지 않는다(dismiss_stale_reviews_on_push false). 그래서 예약됐거나 큐에 있는 PR 중 Viewed 가 다 차지 않은 +// 것은 해제 대상으로 고른다(#2252). 누가 걸었는지는 따지지 않는다. 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const PULL_REQUEST_FIELDS = ` - number isDraft headRefOid reviewDecision isInMergeQueue changedFiles + id number isDraft headRefOid reviewDecision isInMergeQueue changedFiles autoMergeRequest { enabledAt } files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } `; @@ -40,6 +44,30 @@ export function decideViewedAutoMerge(pullRequest, files) { return { enable: true, reason: `모든 파일 Viewed·승인 — 자동 머지 예약` }; } +/** + * 자동 머지 예약 또는 merge queue 등록을 풀어야 하는지 판정한다. + * + * @param {object} pullRequest GraphQL PullRequest 노드 + * @param {Array<{ path: string, viewerViewedState: string }>} files PR 의 전체 변경 파일 + * @returns {{ disableAutoMerge: boolean, dequeue: boolean, reason: string }} + */ +export function decideViewedAutoMergeCancel(pullRequest, files) { + const autoMerge = Boolean(pullRequest.autoMergeRequest); + const queued = Boolean(pullRequest.isInMergeQueue); + if (!autoMerge && !queued) return { disableAutoMerge: false, dequeue: false, reason: "예약·큐 등록 없음" }; + const keep = (reason) => ({ disableAutoMerge: false, dequeue: false, reason }); + const cancel = (reason) => ({ disableAutoMerge: autoMerge, dequeue: queued, reason }); + // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. 예약 쪽과 같은 기준이다. + if (files.length === 0 || files.length !== pullRequest.changedFiles) { + return cancel(`파일 ${files.length}/${pullRequest.changedFiles} 만 읽었다 — 예약·큐 해제`); + } + const unviewed = files.filter((file) => file.viewerViewedState !== "VIEWED"); + if (unviewed.length > 0) { + return cancel(`Viewed 안 된 파일 ${unviewed.length}/${files.length} — 예약·큐 해제`); + } + return keep("이미 자동 머지 예약 또는 merge queue 에 있고 모든 파일 Viewed"); +} + async function graphql(token, apiUrl, query, variables) { const response = await fetch(`${apiUrl}/graphql`, { method: "POST", @@ -99,14 +127,29 @@ async function main() { } const targets = []; + const cancels = []; for (const pullRequest of data.repository.pullRequests.nodes) { const files = await readAllFiles(request, owner, name, pullRequest); + const cancel = decideViewedAutoMergeCancel(pullRequest, files); + if (cancel.disableAutoMerge || cancel.dequeue) { + console.log(`#${pullRequest.number}: ${cancel.reason}`); + cancels.push({ + number: pullRequest.number, + id: pullRequest.id, + disableAutoMerge: cancel.disableAutoMerge, + dequeue: cancel.dequeue, + }); + continue; + } const decision = decideViewedAutoMerge(pullRequest, files); console.log(`#${pullRequest.number}: ${decision.reason}`); if (decision.enable) targets.push({ number: pullRequest.number, headSha: pullRequest.headRefOid }); } if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `targets=${JSON.stringify(targets)}\n`); + appendFileSync( + process.env.GITHUB_OUTPUT, + `targets=${JSON.stringify(targets)}\ncancels=${JSON.stringify(cancels)}\n`, + ); } } diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 1ca002167e..c4e0fd663c 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { decideViewedAutoMerge } from "./find-viewed-auto-merge-targets.mjs"; +import { decideViewedAutoMerge, decideViewedAutoMergeCancel } from "./find-viewed-auto-merge-targets.mjs"; const viewed = (path) => ({ path, viewerViewedState: "VIEWED" }); @@ -49,6 +49,69 @@ test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); }); +const scheduled = { autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }; + +test("예약·큐 등록이 없으면 Viewed 와 무관하게 해제하지 않는다", () => { + const decision = decideViewedAutoMergeCancel(pullRequest(), [viewed("a.kt"), { path: "b.kt", viewerViewedState: "UNVIEWED" }]); + assert.equal(decision.disableAutoMerge, false); + assert.equal(decision.dequeue, false); +}); + +test("예약 뒤 새 push 로 Viewed 가 풀리면 예약을 끈다 — DISMISSED·UNVIEWED", () => { + for (const viewerViewedState of ["DISMISSED", "UNVIEWED"]) { + const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState }]; + const decision = decideViewedAutoMergeCancel(pullRequest(scheduled), files); + assert.equal(decision.disableAutoMerge, true, viewerViewedState); + assert.equal(decision.dequeue, false, viewerViewedState); + } +}); + +test("merge queue 에 있는데 Viewed 가 풀리면 큐에서 빼고, 예약도 있으면 함께 끈다", () => { + const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: "DISMISSED" }]; + assert.deepEqual( + pickActions(decideViewedAutoMergeCancel(pullRequest({ isInMergeQueue: true }), files)), + { disableAutoMerge: false, dequeue: true }, + ); + assert.deepEqual( + pickActions(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, isInMergeQueue: true }), files)), + { disableAutoMerge: true, dequeue: true }, + ); +}); + +test("승인·Draft 상태와 무관하게 Viewed 로만 해제를 가른다", () => { + const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: "UNVIEWED" }]; + for (const overrides of [{ reviewDecision: "APPROVED" }, { reviewDecision: "REVIEW_REQUIRED" }, { isDraft: true }]) { + assert.equal(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, ...overrides }), files).disableAutoMerge, true); + } +}); + +test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않고 해제한다", () => { + assert.equal(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).disableAutoMerge, true); + assert.equal(decideViewedAutoMergeCancel(pullRequest({ isInMergeQueue: true, changedFiles: 0 }), []).dequeue, true); +}); + +test("예약·큐에 있고 모든 파일이 Viewed 면 그대로 둔다", () => { + const files = [viewed("a.kt"), viewed("b.kt")]; + for (const overrides of [scheduled, { isInMergeQueue: true }, { ...scheduled, isInMergeQueue: true }]) { + assert.deepEqual(pickActions(decideViewedAutoMergeCancel(pullRequest(overrides), files)), { disableAutoMerge: false, dequeue: false }); + } +}); + +function pickActions({ disableAutoMerge, dequeue }) { + return { disableAutoMerge, dequeue }; +} + +test("워크플로는 해제 대상의 예약을 끄고 큐에서 빼며, 해제 실패는 red 로 남긴다", async () => { + const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); + assert.match(workflow, /echo 'cancels=\[\]' >> "\$GITHUB_OUTPUT"/); + assert.match(workflow, /CANCELS: \$\{\{ steps\.find\.outputs\.cancels \}\}/); + assert.match(workflow, /gh pr merge "\$number" --disable-auto/); + assert.match(workflow, /dequeuePullRequest\(input: \{ id: \$id \}\)/); + assert.match(workflow, /exit "\$failed"/); + // 해제는 예약보다 먼저 돈다. + assert.ok(workflow.indexOf("--disable-auto") < workflow.indexOf("--match-head-commit")); +}); + test("워크플로는 본인 토큰으로 읽고 GITHUB_TOKEN 으로 승인한 HEAD 만 예약한다", async () => { const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); assert.match(workflow, /^permissions: \{\}$/m); diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 362d0d947b..d7edc99b78 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -2,8 +2,12 @@ # # VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. # 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 -# 변경요청이 남으면 머지되지 않는다. 예약을 멈추려면 파일 하나의 Viewed 를 풀면 된다(다음 실행이 다시 -# 걸지 않는다). 이미 걸린 예약은 PR 화면에서 끈다. +# 변경요청이 남으면 머지되지 않는다. +# +# 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 +# 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 +# 않으므로, 이 단계가 없으면 아무도 다시 보지 않은 커밋이 머지된다. 누가 건 예약인지는 따지지 않는다. 예약을 +# 멈추려면 파일 하나의 Viewed 를 풀면 되고, 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. # # Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 # 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN @@ -26,7 +30,7 @@ permissions: {} jobs: enable: - name: Enable auto-merge for fully viewed PRs + name: Sync auto-merge with Viewed state runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -40,7 +44,7 @@ jobs: persist-credentials: false sparse-checkout: .github/scripts - - name: Find fully viewed approved PRs + - name: Find PRs to enable or cancel auto-merge id: find env: VIEWED_TOKEN: ${{ secrets.VIEWED_TOKEN }} @@ -52,12 +56,39 @@ jobs: if [ -z "$VIEWED_TOKEN" ]; then echo "::notice::VIEWED_TOKEN 시크릿이 없어 건너뜁니다" echo 'targets=[]' >> "$GITHUB_OUTPUT" + echo 'cancels=[]' >> "$GITHUB_OUTPUT" exit 0 fi node .github/scripts/find-viewed-auto-merge-targets.mjs + - name: Cancel auto-merge for PRs no longer fully viewed + if: steps.find.outputs.cancels != '[]' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + CANCELS: ${{ steps.find.outputs.cancels }} + run: | + # 예약을 먼저 끄고 큐에서 뺀다. 해제는 HEAD 를 따지지 않는다 — 판정 뒤 Viewed 가 다 찼더라도 다음 + # 실행이 다시 건다. 해제가 실패하면 보지 않은 커밋이 머지될 수 있으므로 job 을 red 로 남긴다. + failed=0 + while read -r target; do + number=$(jq -r '.number' <<< "$target") + if [ "$(jq -r '.disableAutoMerge' <<< "$target")" = true ] && ! gh pr merge "$number" --disable-auto; then + echo "::error::#$number 자동 머지 예약 해제 실패" + failed=1 + fi + if [ "$(jq -r '.dequeue' <<< "$target")" = true ] && ! gh api graphql \ + -f query='mutation($id: ID!) { dequeuePullRequest(input: { id: $id }) { clientMutationId } }' \ + -f id="$(jq -r '.id' <<< "$target")" > /dev/null; then + echo "::error::#$number merge queue 해제 실패" + failed=1 + fi + done < <(jq -c '.[]' <<< "$CANCELS") + exit "$failed" + - name: Enable auto-merge - if: steps.find.outputs.targets != '[]' + # 해제 단계가 red 여도 다른 PR 의 예약은 그대로 판정한다. + if: ${{ !cancelled() && steps.find.outcome == 'success' && steps.find.outputs.targets != '[]' }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} From d3a58a634f8d011814764407925c56f8f9207684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 07:53:50 +0000 Subject: [PATCH 067/116] =?UTF-8?q?ci(review):=20dequeue=20GraphQL=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EC=9D=98=20SC2016=20=EC=9D=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B4=80=EB=A1=80=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=81=88=EB=8B=A4=20(#2252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/viewed-auto-merge.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index d7edc99b78..dfed7bcbb6 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -77,6 +77,7 @@ jobs: echo "::error::#$number 자동 머지 예약 해제 실패" failed=1 fi + # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -f 값으로 바인딩한다. if [ "$(jq -r '.dequeue' <<< "$target")" = true ] && ! gh api graphql \ -f query='mutation($id: ID!) { dequeuePullRequest(input: { id: $id }) { clientMutationId } }' \ -f id="$(jq -r '.id' <<< "$target")" > /dev/null; then From 35efc25643295fe05324e53794d2c23b6778328a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:10:35 +0000 Subject: [PATCH 068/116] =?UTF-8?q?fix(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=C2=B7=EC=A0=84=EB=8B=AC=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=A0=80=EC=9E=A5=20=EC=9E=A0=EA=B8=88=EC=9D=84=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=ED=95=9C=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 화면은 성공 때 isSaving 을 풀고 완료 신호(pendingEvent, isSaved)로 재제출을 막았다. 신호는 ObserveSignal 이 화면을 닫는 호출 직후 바로 소비되므로, 퇴장 애니메이션 동안에는 버튼이 다시 켜지고 가드도 열려 같은 저장 요청이 한 번 더 나갈 수 있었다. 프로필 편집과 같이 성공 뒤 isSaving 을 유지해 잠금은 isSaving 하나가, 전달은 신호가 맡게 한다. --- .../delivery/DeliveryConditionViewModel.kt | 5 +++-- .../presentation/receiver/ReceiverEditViewModel.kt | 5 +++-- .../setting/presentation/SettingCompletionTest.kt | 13 +++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 6789a7eb8d..5caff65892 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -69,8 +69,9 @@ internal class DeliveryConditionViewModel state.copy(isSaving = true) } + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true) + state.copy(conditions = event.conditions, isSaved = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -100,7 +101,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving || state.isSaved) return + if (!state.isInitialized || state.isSaving) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 55e922dd8b..20d5468d09 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -42,7 +42,8 @@ internal class ReceiverEditViewModel is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) - ReceiverEditReducerEvent.Saved -> state.copy(isSaving = false, pendingEvent = ReceiverEditEvent.EditSuccess) + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. + ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) } @@ -69,7 +70,7 @@ internal class ReceiverEditViewModel email: String, message: String, ) { - if (currentState.isSaving || currentState.pendingEvent != null) return + if (currentState.isSaving) return if (!email.isValidReceiverEmail()) { dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 94a3c0b89c..7b1b5ec5af 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -464,9 +464,10 @@ class SettingCompletionTest { } composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } assertEquals(null, viewModel.uiState.value.pendingEvent) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - !viewModel.uiState.value.isSaving - } + // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. + assertTrue(viewModel.uiState.value.isSaving) + composeRule.runOnIdle(update) + composeRule.waitForIdle() assertEquals( listOf(expectedBasicCall, expectedBasicCall, expectedBasicCall), repository.receiverUpdateCalls, @@ -603,7 +604,11 @@ class SettingCompletionTest { composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } - assertFalse(viewModel.uiState.value.isSaving) + // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. + assertTrue(viewModel.uiState.value.isSaving) + composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } + composeRule.waitForIdle() + assertEquals(listOf(expectedCall, expectedCall), repository.deliveryUpdateCalls) } @Test From 9c004344971e17b39c64039054a7998daa7bc687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:13:03 +0000 Subject: [PATCH 069/116] =?UTF-8?q?style(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95=20=EB=A6=AC=EB=93=80=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=82=AC=EC=9D=B4=EC=97=90=20=EB=B9=88=20?= =?UTF-8?q?=EC=A4=84=EC=9D=84=20=EB=91=94=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주석이 붙은 Saved 분기가 여러 줄로 잡혀 ktlint blank-line-between-when-conditions 가 실패했다. --- .../setting/presentation/receiver/ReceiverEditViewModel.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 20d5468d09..209b734be3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -39,12 +39,18 @@ internal class ReceiverEditViewModel ): ReceiverEditUiState = when (event) { is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) + is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) + ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) + is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) } From 2861ce6150f84e75fb6519c6689a9fcf52a690b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:18:01 +0000 Subject: [PATCH 070/116] =?UTF-8?q?refactor(setting):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=EC=82=AC=EC=8B=A4=EC=9D=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=94=8C=EB=9E=98=EA=B7=B8=EC=99=80=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=EC=97=90=EC=84=9C=20=EB=96=BC=EC=96=B4=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성공 뒤에도 isSaving·isUpdating 을 true 로 두면 저장이 끝났는데 상태가 저장 중이라고 말하게 된다. 세 편집 화면에 되돌리지 않는 완료 사실(isSaved·isUpdated)을 두고, 진행 플래그는 성공 때 풀며, 재제출 가드와 버튼은 isSaveLocked·isUpdateLocked 로 둘을 함께 본다. 전달 조건의 화면 닫기 신호는 완료 사실과 이름이 겹치지 않게 shouldNavigateBack 으로 옮겼고, ReceiverRegisterContent 의 isLoading 은 실제 쓰임대로 isActionLocked 로 바꿨다. --- .../SettingEditorSaveSignalAndroidTest.kt | 2 +- .../delivery/DeliveryConditionScreen.kt | 4 +-- .../delivery/DeliveryConditionUiState.kt | 6 +++- .../delivery/DeliveryConditionViewModel.kt | 7 ++--- .../presentation/profile/ProfileEditScreen.kt | 2 +- .../profile/ProfileEditUiState.kt | 6 +++- .../profile/ProfileEditViewModel.kt | 7 +++-- .../receiver/ReceiverEditContent.kt | 2 +- .../receiver/ReceiverEditUiState.kt | 6 +++- .../receiver/ReceiverEditViewModel.kt | 31 +++++++++++++------ .../receiver/ReceiverRegisterScreen.kt | 6 ++-- .../presentation/SettingCompletionTest.kt | 14 +++++---- .../profile/ProfileEditMviTest.kt | 3 +- 13 files changed, 62 insertions(+), 34 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index 78f61b9665..c2af8e80dc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -43,7 +43,7 @@ import javax.inject.Inject /** * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). * - * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaved`)가 화면에서 한 번만 + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `shouldNavigateBack`)가 화면에서 한 번만 * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 * ViewModel 단위 테스트로는 보이지 않는 자리다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index f90fa76601..2a6a44dfd0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -42,7 +42,7 @@ internal fun DeliveryConditionScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() ObserveFlag( - raised = uiState.isSaved, + raised = uiState.shouldNavigateBack, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, onRaised = onSaveSuccess, @@ -85,7 +85,7 @@ private fun DeliveryConditionContent( title = stringResource(R.string.setting_recipient_after_delivery), onBackClick = onBack, actions = { - val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaving + val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaveLocked TextButton(onClick = onSave, enabled = isSaveEnabled) { Text( text = stringResource(R.string.setting_delivery_condition_save), diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index 7ba60d8f0e..c75426711a 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -14,8 +14,12 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, + /** 저장을 마쳤다는 사실. 소비되는 [shouldNavigateBack] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ val isSaved: Boolean = false, -) : UiState + val shouldNavigateBack: Boolean = false, +) : UiState { + val isSaveLocked: Boolean get() = isSaving || isSaved +} enum class DeliveryConditionError { LOAD_FAILED, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 5caff65892..19d64a41d8 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -69,9 +69,8 @@ internal class DeliveryConditionViewModel state.copy(isSaving = true) } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. is DeliveryConditionReducerEvent.Saved -> { - state.copy(conditions = event.conditions, isSaved = true) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true, shouldNavigateBack = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -79,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(isSaved = false) + state.copy(shouldNavigateBack = false) } } @@ -101,7 +100,7 @@ internal class DeliveryConditionViewModel private fun onSave() { val state = currentState - if (!state.isInitialized || state.isSaving) return + if (!state.isInitialized || state.isSaveLocked) return val hasTimeLetterCondition = state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt index 11f779540f..7caf1537d7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditScreen.kt @@ -204,7 +204,7 @@ private fun ProfileEditForm( AfternoteButton( text = "수정하기", onClick = { onUpdateClick(nameState.text.toString(), phoneState.text.toString()) }, - type = if (state.isUpdating) AfternoteButtonType.Un else AfternoteButtonType.Default, + type = if (state.isUpdateLocked) AfternoteButtonType.Un else AfternoteButtonType.Default, modifier = Modifier .padding(horizontal = 20.dp) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt index f6465f472c..28d930b9ba 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditUiState.kt @@ -10,8 +10,12 @@ internal sealed interface ProfileEditUiState : UiState { val phone: String, val email: String, val isUpdating: Boolean = false, + /** 수정을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isUpdated: Boolean = false, val pendingEvent: ProfileEditEvent? = null, - ) : ProfileEditUiState + ) : ProfileEditUiState { + val isUpdateLocked: Boolean get() = isUpdating || isUpdated + } data object Error : ProfileEditUiState } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt index f442a5586f..3f9f300b93 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditViewModel.kt @@ -42,9 +42,10 @@ internal class ProfileEditViewModel state.updateSuccess { it.copy(isUpdating = true, pendingEvent = null) } } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isUpdating 을 유지한다. ProfileEditReducerEvent.UpdateSucceeded -> { - state.updateSuccess { it.copy(pendingEvent = ProfileEditEvent.UpdateSuccess) } + state.updateSuccess { + it.copy(isUpdating = false, isUpdated = true, pendingEvent = ProfileEditEvent.UpdateSuccess) + } } ProfileEditReducerEvent.UpdateFailed -> { @@ -78,7 +79,7 @@ internal class ProfileEditViewModel phone: String, ) { val current = currentState as? ProfileEditUiState.Success ?: return - if (current.isUpdating) return + if (current.isUpdateLocked) return dispatch(ProfileEditReducerEvent.Updating) viewModelScope.launch { runCatchingCancellable { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt index 12791f9ea6..dbca359d50 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditContent.kt @@ -31,7 +31,7 @@ internal fun ReceiverEditContent( title = "수신자 수정", actionText = "수정", isPhoneRequired = true, - isLoading = uiState.isSaving, + isActionLocked = uiState.isSaveLocked, errorMessage = uiState.errorMessage, onBackClick = onBackClick, onRegister = onRegister, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt index c4fc89585d..76d436b7c5 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditUiState.kt @@ -7,10 +7,14 @@ import com.afternote.core.ui.mvi.UiState internal data class ReceiverEditUiState( val isLoading: Boolean = true, val isSaving: Boolean = false, + /** 저장을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + val isSaved: Boolean = false, val receiver: ReceiverDetail? = null, val errorMessage: UiText? = null, val pendingEvent: ReceiverEditEvent? = null, -) : UiState +) : UiState { + val isSaveLocked: Boolean get() = isSaving || isSaved +} internal sealed interface ReceiverEditEvent { data object EditSuccess : ReceiverEditEvent diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 209b734be3..47e1446fa9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -38,20 +38,33 @@ internal class ReceiverEditViewModel event: ReceiverEditReducerEvent, ): ReceiverEditUiState = when (event) { - is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) + is ReceiverEditReducerEvent.Loaded -> { + state.copy(isLoading = false, receiver = event.receiver) + } - is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + is ReceiverEditReducerEvent.LoadFailed -> { + state.copy(isLoading = false, errorMessage = event.message) + } - is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) + is ReceiverEditReducerEvent.ValidationFailed -> { + state.copy(errorMessage = event.message) + } - ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + ReceiverEditReducerEvent.Saving -> { + state.copy(isSaving = true, errorMessage = null) + } - // 성공 뒤 화면이 닫히는 사이 재제출을 막도록 isSaving 을 유지한다. - ReceiverEditReducerEvent.Saved -> state.copy(pendingEvent = ReceiverEditEvent.EditSuccess) + ReceiverEditReducerEvent.Saved -> { + state.copy(isSaving = false, isSaved = true, pendingEvent = ReceiverEditEvent.EditSuccess) + } - is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + is ReceiverEditReducerEvent.SaveFailed -> { + state.copy(isSaving = false, errorMessage = event.message) + } - ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) + ReceiverEditReducerEvent.SuccessConsumed -> { + state.copy(pendingEvent = null) + } } init { @@ -76,7 +89,7 @@ internal class ReceiverEditViewModel email: String, message: String, ) { - if (currentState.isSaving) return + if (currentState.isSaveLocked) return if (!email.isValidReceiverEmail()) { dispatch(ReceiverEditReducerEvent.ValidationFailed(UiText.Resource(R.string.setting_receiver_email_invalid))) return diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt index d140f22595..181d9cfcb3 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverRegisterScreen.kt @@ -74,7 +74,7 @@ fun ReceiverRegisterScreen( title = "수신자 등록", actionText = "등록", isPhoneRequired = true, - isLoading = uiState.isLoading, + isActionLocked = uiState.isLoading, errorMessage = uiState.errorMessage, onBackClick = onBackClick, onRegister = viewModel::register, @@ -87,7 +87,7 @@ internal fun ReceiverRegisterContent( title: String, actionText: String, isPhoneRequired: Boolean, - isLoading: Boolean, + isActionLocked: Boolean, errorMessage: UiText?, onBackClick: () -> Unit, onRegister: (name: String, relation: String, phone: String, email: String, message: String) -> Unit, @@ -149,7 +149,7 @@ internal fun ReceiverRegisterContent( messageState.text.toString(), ) }, - enabled = isFormValid && !isLoading, + enabled = isFormValid && !isActionLocked, colors = ButtonDefaults.textButtonColors( contentColor = AfternoteDesign.colors.gray9, diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index 7b1b5ec5af..dfe115d1d2 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -464,8 +464,9 @@ class SettingCompletionTest { } composeRule.runOnIdle { viewModel.onIntent(ReceiverEditIntent.ConsumeSuccess) } assertEquals(null, viewModel.uiState.value.pendingEvent) - // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. - assertTrue(viewModel.uiState.value.isSaving) + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. + assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) composeRule.runOnIdle(update) composeRule.waitForIdle() assertEquals( @@ -598,14 +599,15 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaved } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.shouldNavigateBack } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertFalse(viewModel.uiState.value.isSaved) + assertFalse(viewModel.uiState.value.shouldNavigateBack) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } - // 신호를 소비한 뒤에도 화면이 닫히는 동안에는 잠금이 남아 재제출을 무시한다. - assertTrue(viewModel.uiState.value.isSaving) + // 신호를 소비해도 저장 완료 사실은 남아, 화면이 닫히는 동안의 재제출을 무시한다. + assertFalse(viewModel.uiState.value.isSaving) + assertTrue(viewModel.uiState.value.isSaved) composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.Save) } composeRule.waitForIdle() assertEquals(listOf(expectedCall, expectedCall), repository.deliveryUpdateCalls) diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt index 7ec3290920..b46c6bf4ba 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/profile/ProfileEditMviTest.kt @@ -85,7 +85,8 @@ class ProfileEditMviTest { viewModel.onIntent(ProfileEditIntent.ConsumeEvent(ProfileEditEvent.UpdateSuccess)) assertNull(viewModel.success().pendingEvent) - assertTrue(viewModel.success().isUpdating) + assertFalse(viewModel.success().isUpdating) + assertTrue(viewModel.success().isUpdated) } @Test From 1ef061372f74f6f0c106488610077913153a84f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:30:57 +0000 Subject: [PATCH 071/116] =?UTF-8?q?refactor(setting):=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=A0=80=EC=9E=A5=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=20=EC=8B=A0=ED=98=B8=EB=A5=BC=20=EC=9D=B4=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EA=B1=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=91=94=EB=8B=A4=20(#2?= =?UTF-8?q?179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldNavigateBack 은 ViewModel 이 뒤로 가기를 정하는 것처럼 읽혔다. 이동은 onSaveSuccess 를 받은 호스트가 정하므로, 신호를 콜백·소비 Intent 와 같은 사건(저장 성공)으로 isSaveSuccessPending 이라 부른다. --- .../afternote_fe/SettingEditorSaveSignalAndroidTest.kt | 2 +- .../setting/presentation/delivery/DeliveryConditionScreen.kt | 2 +- .../presentation/delivery/DeliveryConditionUiState.kt | 5 +++-- .../presentation/delivery/DeliveryConditionViewModel.kt | 4 ++-- .../feature/setting/presentation/SettingCompletionTest.kt | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt index c2af8e80dc..670eaaf970 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -43,7 +43,7 @@ import javax.inject.Inject /** * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). * - * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `shouldNavigateBack`)가 화면에서 한 번만 + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaveSuccessPending`)가 화면에서 한 번만 * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 * ViewModel 단위 테스트로는 보이지 않는 자리다. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt index 2a6a44dfd0..d42a0abc5b 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionScreen.kt @@ -42,7 +42,7 @@ internal fun DeliveryConditionScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() ObserveFlag( - raised = uiState.shouldNavigateBack, + raised = uiState.isSaveSuccessPending, consumed = DeliveryConditionIntent.ConsumeSuccess, onIntent = viewModel::onIntent, onRaised = onSaveSuccess, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt index c75426711a..2039fc4b8f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionUiState.kt @@ -14,9 +14,10 @@ internal data class DeliveryConditionUiState( val conditions: List = emptyList(), val error: DeliveryConditionError? = null, val isSaving: Boolean = false, - /** 저장을 마쳤다는 사실. 소비되는 [shouldNavigateBack] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ + /** 저장을 마쳤다는 사실. 소비되는 [isSaveSuccessPending] 과 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */ val isSaved: Boolean = false, - val shouldNavigateBack: Boolean = false, + /** 화면에 알릴 저장 성공 신호. 어디로 이동할지는 이 신호를 콜백으로 받은 호스트가 정한다. */ + val isSaveSuccessPending: Boolean = false, ) : UiState { val isSaveLocked: Boolean get() = isSaving || isSaved } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt index 19d64a41d8..2f86862f49 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/delivery/DeliveryConditionViewModel.kt @@ -70,7 +70,7 @@ internal class DeliveryConditionViewModel } is DeliveryConditionReducerEvent.Saved -> { - state.copy(isSaving = false, conditions = event.conditions, isSaved = true, shouldNavigateBack = true) + state.copy(isSaving = false, conditions = event.conditions, isSaved = true, isSaveSuccessPending = true) } DeliveryConditionReducerEvent.SaveFailed -> { @@ -78,7 +78,7 @@ internal class DeliveryConditionViewModel } DeliveryConditionReducerEvent.SuccessConsumed -> { - state.copy(shouldNavigateBack = false) + state.copy(isSaveSuccessPending = false) } } diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt index dfe115d1d2..2ec681531c 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/SettingCompletionTest.kt @@ -599,9 +599,9 @@ class SettingCompletionTest { ), ) - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.shouldNavigateBack } + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.isSaveSuccessPending } composeRule.runOnIdle { viewModel.onIntent(DeliveryConditionIntent.ConsumeSuccess) } - assertFalse(viewModel.uiState.value.shouldNavigateBack) + assertFalse(viewModel.uiState.value.isSaveSuccessPending) composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { viewModel.uiState.value.conditions == serverConditions } From 90af61a5df428015fc61a3bc51964e1a167f02c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 08:42:46 +0000 Subject: [PATCH 072/116] =?UTF-8?q?style(setting):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95=20=EB=A6=AC=EB=93=80=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9B=90=EB=9E=98=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4=20(#2179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved 분기만 중괄호로 감싸 when-entry-bracing 이 모든 분기를 바꾸게 했다. 한 줄로도 ktlint 를 통과하므로 바뀐 내용(isSaved = true)만 diff 에 남긴다. --- .../receiver/ReceiverEditViewModel.kt | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt index 47e1446fa9..6da2b1aaee 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverEditViewModel.kt @@ -38,33 +38,13 @@ internal class ReceiverEditViewModel event: ReceiverEditReducerEvent, ): ReceiverEditUiState = when (event) { - is ReceiverEditReducerEvent.Loaded -> { - state.copy(isLoading = false, receiver = event.receiver) - } - - is ReceiverEditReducerEvent.LoadFailed -> { - state.copy(isLoading = false, errorMessage = event.message) - } - - is ReceiverEditReducerEvent.ValidationFailed -> { - state.copy(errorMessage = event.message) - } - - ReceiverEditReducerEvent.Saving -> { - state.copy(isSaving = true, errorMessage = null) - } - - ReceiverEditReducerEvent.Saved -> { - state.copy(isSaving = false, isSaved = true, pendingEvent = ReceiverEditEvent.EditSuccess) - } - - is ReceiverEditReducerEvent.SaveFailed -> { - state.copy(isSaving = false, errorMessage = event.message) - } - - ReceiverEditReducerEvent.SuccessConsumed -> { - state.copy(pendingEvent = null) - } + is ReceiverEditReducerEvent.Loaded -> state.copy(isLoading = false, receiver = event.receiver) + is ReceiverEditReducerEvent.LoadFailed -> state.copy(isLoading = false, errorMessage = event.message) + is ReceiverEditReducerEvent.ValidationFailed -> state.copy(errorMessage = event.message) + ReceiverEditReducerEvent.Saving -> state.copy(isSaving = true, errorMessage = null) + ReceiverEditReducerEvent.Saved -> state.copy(isSaving = false, isSaved = true, pendingEvent = ReceiverEditEvent.EditSuccess) + is ReceiverEditReducerEvent.SaveFailed -> state.copy(isSaving = false, errorMessage = event.message) + ReceiverEditReducerEvent.SuccessConsumed -> state.copy(pendingEvent = null) } init { From 3fc4c7e603decd3c5e915edaaf0934c2a1229977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 05:00:24 +0000 Subject: [PATCH 073/116] =?UTF-8?q?ci(merge-queue):=20=ED=81=90=20?= =?UTF-8?q?=ED=88=AC=EC=9E=85=EC=9D=84=20=EC=82=AC=EB=9E=8C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EB=8B=A4=20(#2254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_TOKEN 으로 넣은 큐 항목은 merge_group CI 를 띄우지 못해 큐 맨 앞을 60분 붙잡고 CI_TIMEOUT 으로 빠진다. 자동 머지 예약은 MERGE_QUEUE_TOKEN 으로 하고, 방출 처리의 재투입은 시크릿을 쓰는 workflow_dispatch 워크플로로 넘긴다. 시크릿이 없으면 GITHUB_TOKEN 으로 넣지 않는다. --- .../find-viewed-auto-merge-targets.test.mjs | 7 +- .../scripts/handle-merge-queue-dequeue.mjs | 122 +++++++++++++++--- .../handle-merge-queue-dequeue.test.mjs | 107 +++++++++++++++ .github/workflows/merge-queue-dequeue.yml | 9 +- .github/workflows/merge-queue-requeue.yml | 53 ++++++++ .github/workflows/viewed-auto-merge.yml | 15 ++- 6 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/merge-queue-requeue.yml diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 1ca002167e..0db9f9a3ce 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -49,13 +49,16 @@ test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); }); -test("워크플로는 본인 토큰으로 읽고 GITHUB_TOKEN 으로 승인한 HEAD 만 예약한다", async () => { +test("워크플로는 본인 토큰으로 읽고 사람 토큰으로 승인한 HEAD 만 예약한다", async () => { const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); assert.match(workflow, /^permissions: \{\}$/m); assert.match(workflow, /VIEWED_TOKEN: \$\{\{ secrets\.VIEWED_TOKEN \}\}/); assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); assert.match(workflow, /persist-credentials: false/); - assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + // GITHUB_TOKEN 으로 예약·투입하면 merge_group CI 가 뜨지 않아 큐가 60분 막힌다(1005 #2208). + assert.match(workflow, /GH_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/); + assert.doesNotMatch(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match(workflow, /if \[ -z "\$GH_TOKEN" \]; then[\s\S]*?exit 0/); assert.match(workflow, /--match-head-commit "\$head_sha"/); // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. assert.doesNotMatch(workflow, /--admin/); diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 706d1c04e9..c05e6be0ad 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -25,6 +25,12 @@ // merge-async 는 202 pending 으로 받으므로 결과(enqueued·merged·failed)를 폴링해 확인하고, failed·미확정은 job 을 // red 로 남긴다. 스택 위쪽 PR 은 자동 재투입하지 않는다 — merge-async 가 downstack 의 열린 PR 까지 함께 넣어 // 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮기 때문이다. 위쪽 PR 에는 마커 없는 안내 코멘트만 남긴다. +// +// 재투입은 이 job 이 직접 하지 않고 merge-queue-requeue.yml 에 넘긴다(#2254). GITHUB_TOKEN 으로 큐에 넣으면 GitHub 이 +// 그 이벤트로 워크플로를 띄우지 않아 merge_group CI 가 하나도 돌지 않고, 큐 맨 앞을 60분(check timeout) 붙잡은 뒤 +// CI_TIMEOUT 으로 다시 빠지면서 뒤의 PR 들까지 다시 검증하게 만든다(1005 #2208 실측: 봇이 넣은 그룹만 체크 0개). +// 투입에는 사람 토큰(MERGE_QUEUE_TOKEN)이 필요한데 이 워크플로는 pull_request_target 브리지라 시크릿을 쓰지 않는다. +// workflow_dispatch 는 GITHUB_TOKEN 으로 보내도 실행되는 예외라서, 판정과 마커는 여기서 끝내고 투입만 dispatch 로 넘긴다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -329,6 +335,67 @@ export async function enqueueStacked(api, repository, number, headSha, { sleep = } } +export const REQUEUE_WORKFLOW_FILE = "merge-queue-requeue.yml"; + +/** 재투입 워크플로를 띄운다. 입력의 head 로 고정해 그사이 새 커밋이 올라오면 그쪽이 투입하지 않는다. */ +export async function dispatchRequeue(api, repository, { number, headSha, ref }) { + await api(`/repos/${repository}/actions/workflows/${REQUEUE_WORKFLOW_FILE}/dispatches`, { + method: "POST", + body: { ref, inputs: { pull_request_number: String(number), head_sha: headSha } }, + }); +} + +/** 큐 투입 자체. 스택 PR 은 merge-async, 나머지는 GraphQL 이다(#2177). [api] 의 토큰 주인이 투입 주체가 된다. */ +export async function performRequeue({ api, repository, number, live, logger = console, sleep = defaultSleep }) { + const headSha = live.headRefOid; + if (isStackMember(live)) { + const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); + if (enqueueResult.unresolved) { + const refreshed = await fetchLivePullRequest(api, repository, number); + if (!refreshed?.mergeQueueEntry) { + throw new Error(`merge-async 결과 미확정: ${JSON.stringify(enqueueResult)} 큐에도 없다`); + } + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → live 재조회로 큐 진입 확인(${refreshed.mergeQueueEntry.state})`); + } else { + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → ${enqueueResult?.status ?? "?"} ${enqueueResult?.details?.message ?? ""}`.trimEnd()); + } + return "merge-async"; + } + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + return "graphql"; +} + +/** + * merge-queue-requeue.yml 쪽. dispatch 를 받은 뒤 live 상태를 다시 보고, 판정한 head 그대로 열려 있고 큐 밖일 때만 넣는다. + * 판정·마커는 방출 처리 job 이 이미 끝냈으므로 여기서는 코멘트를 쓰지 않는다. + */ +export async function requeueDispatched({ api, repository, number, headSha, logger = console, sleep = defaultSleep }) { + const live = await fetchLivePullRequest(api, repository, number); + if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); + let skip = null; + if (live.state !== "OPEN") skip = live.state; + else if (live.mergeQueueEntry) skip = "already-queued"; + else if (live.headRefOid !== headSha) skip = "head-changed"; + else if (isStackMember(live) && !isStackBottom(live)) skip = "stack-upper"; + if (skip) { + logger.log(`#${number} 재투입 생략(${skip}) — 판정 head ${shortSha(headSha)}, 지금 head ${shortSha(live.headRefOid ?? "")}`); + return { action: "none", why: skip }; + } + const enqueueMethod = await performRequeue({ api, repository, number, live, logger, sleep }); + return { action: "requeue", enqueueMethod }; +} + +export function renderMissingQueueTokenComment({ headSha, repository, number, stack = false }) { + return [ + "### merge queue 방출: 재투입하지 못했다", + "", + `head \`${shortSha(headSha)}\`. \`MERGE_QUEUE_TOKEN\` 시크릿이 없다.`, + "GITHUB_TOKEN 으로 넣으면 merge_group CI 가 돌지 않아 60분 뒤 CI_TIMEOUT 으로 다시 빠지므로 넣지 않았다.", + `\`${manualEnqueueCommand({ repository, number, stack })}\` 로 직접 투입한다.`, + ].join("\n"); +} + export async function handleDequeue({ api, repository, @@ -337,6 +404,7 @@ export async function handleDequeue({ dryRun = false, logger = console, sleep = defaultSleep, + requeue = null, }) { const live = await fetchLivePullRequest(api, repository, number); if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); @@ -372,33 +440,46 @@ export async function handleDequeue({ } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. await postComment(api, repository, number, renderRequeueComment({ reason, headSha, failedJobs })); - if (stack) { - const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); - if (enqueueResult.unresolved) { - const refreshed = await fetchLivePullRequest(api, repository, number); - if (!refreshed?.mergeQueueEntry) { - throw new Error(`merge-async 결과 미확정: ${JSON.stringify(enqueueResult)} 큐에도 없다`); - } - logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → live 재조회로 큐 진입 확인(${refreshed.mergeQueueEntry.state})`); - } else { - logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → ${enqueueResult?.status ?? "?"} ${enqueueResult?.details?.message ?? ""}`.trimEnd()); - } - result.enqueueMethod = "merge-async"; - } else { - const entry = await enqueue(api, live.id); - logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); - result.enqueueMethod = "graphql"; - } + result.enqueueMethod = requeue + ? await requeue({ number, headSha, live }) + : await performRequeue({ api, repository, number, live, logger, sleep }); } return result; } +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} 가 필요합니다.`); + return value; +} + +async function requeueMain(repository) { + const commentApi = createApi(requiredEnv("GITHUB_TOKEN")); + const number = Number(process.env.PULL_REQUEST_NUMBER); + const headSha = requiredEnv("HEAD_SHA"); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("PULL_REQUEST_NUMBER 가 양의 정수여야 합니다."); + } + const queueToken = process.env.MERGE_QUEUE_TOKEN; + if (!queueToken) { + const live = await fetchLivePullRequest(commentApi, repository, number); + await postComment(commentApi, repository, number, renderMissingQueueTokenComment({ headSha, repository, number, stack: isStackMember(live) })); + throw new Error("MERGE_QUEUE_TOKEN 시크릿이 없어 재투입하지 못했다."); + } + const result = await requeueDispatched({ api: createApi(queueToken), repository, number, headSha }); + console.log(`merge-queue-requeue: #${number} head=${shortSha(headSha)} → ${result.action}${result.why ? ` (${result.why})` : ""}${result.enqueueMethod ? ` (${result.enqueueMethod})` : ""}`); +} + async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; if (!token || !repository) { throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); } + if (process.env.MODE === "requeue") { + await requeueMain(repository); + return; + } let number = Number(process.env.PULL_REQUEST_NUMBER); let reason = process.env.DEQUEUE_REASON; if ((!Number.isInteger(number) || number <= 0 || !reason) && process.env.GITHUB_EVENT_PATH) { @@ -412,7 +493,12 @@ async function main() { reason = reason || "UNKNOWN"; const api = createApi(token); - const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true" }); + const ref = requiredEnv("DEFAULT_BRANCH"); + const requeue = async ({ headSha }) => { + await dispatchRequeue(api, repository, { number, headSha, ref }); + return "dispatch"; + }; + const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true", requeue }); const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}${result.stack !== null ? ` (스택 ${result.stack}, ${result.enqueueMethod ?? "판정만"})` : ""}`; console.log(summary); if (process.env.GITHUB_STEP_SUMMARY) { diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index 670a79d9aa..f75dabb935 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,9 +7,13 @@ import { collectFailedJobs, countRequeuesForHead, decide, + dispatchRequeue, provenInfrastructureJobNames, enqueueStacked, handleDequeue, + renderMissingQueueTokenComment, + requeueDispatched, + REQUEUE_WORKFLOW_FILE, isStackBottom, isStackMember, lastEnqueuedAt, @@ -225,6 +229,7 @@ function fakeApi({ if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; if (apiPath.includes("/comments") && (options.method ?? "GET") === "GET") return comments; if (apiPath.includes("/comments") && options.method === "POST") return { id: 1 }; + if (apiPath.endsWith("/dispatches") && options.method === "POST") return null; throw new Error(`unexpected ${apiPath}`); }; api.calls = calls; @@ -605,3 +610,105 @@ test("워크플로는 default branch 정의로 dequeued 에만 반응하고, 테 assert.match(source, /DEQUEUE_REASON: \$\{\{ github\.event\.reason \}\}/); assert.match(source, /cancel-in-progress: false/); }); + +// ---- 재투입은 사람 토큰을 가진 워크플로가 한다 (1005 #2208) ---- +// GITHUB_TOKEN 으로 넣은 큐 항목은 merge_group CI 를 띄우지 못해 60분 뒤 CI_TIMEOUT 으로 빠진다. + +const dispatches = (api) => api.calls.filter((call) => call.apiPath.endsWith("/dispatches")); + +test("방출 처리에 재투입 위임을 주면 마커를 먼저 남기고 직접 넣지 않는다", async () => { + for (const live of [openLive, stackedLive]) { + const api = fakeApi({ live }); + const delegated = []; + const result = await handleDequeue({ + api, repository: REPO, number: 1509, reason: "CI_TIMEOUT", logger: silent, + requeue: async (request) => { + delegated.push(request); + assert.equal(commentPosts(api).length, 1, "마커 코멘트가 위임보다 먼저다"); + return "dispatch"; + }, + }); + assert.equal(result.action, "requeue"); + assert.equal(result.enqueueMethod, "dispatch"); + assert.equal(delegated.length, 1); + assert.equal(delegated[0].number, 1509); + assert.equal(delegated[0].headSha, HEAD); + assert.ok(commentPosts(api)[0].body.body.startsWith(requeueMarker(HEAD))); + assert.equal(enqueueMutations(api).length, 0); + assert.equal(mergeAsyncCalls(api).length, 0); + } +}); + +test("재투입 위임은 재투입 판정일 때만 부른다", async () => { + const runs = [{ id: 7, name: "Unit Test", html_url: "https://x/runs/7", head_branch: queueBranchPrefix("develop", 1509) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 7: [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/71" }] }; + const cases = [ + fakeApi({ live: openLive, runs, jobs }), + fakeApi({ live: openLive, comments: [{ body: renderRequeueComment({ reason: "X", headSha: HEAD }) }] }), + fakeApi({ live: { ...openLive, state: "MERGED" } }), + ]; + for (const api of cases) { + await handleDequeue({ + api, repository: REPO, number: 1509, reason: "CI_TIMEOUT", logger: silent, + requeue: async () => assert.fail("재투입 판정이 아니면 위임하지 않는다"), + }); + } +}); + +test("dispatchRequeue 는 판정한 head 를 입력으로 재투입 워크플로를 띄운다", async () => { + const api = fakeApi({ live: openLive }); + await dispatchRequeue(api, REPO, { number: 2208, headSha: HEAD, ref: "develop" }); + assert.deepEqual(dispatches(api).map(({ apiPath, method, body }) => ({ apiPath, method, body })), [{ + apiPath: `/repos/${REPO}/actions/workflows/${REQUEUE_WORKFLOW_FILE}/dispatches`, + method: "POST", + body: { ref: "develop", inputs: { pull_request_number: "2208", head_sha: HEAD } }, + }]); +}); + +test("requeueDispatched 는 판정한 head 그대로 열려 있고 큐 밖일 때만 넣는다", async () => { + const plain = fakeApi({ live: openLive }); + assert.deepEqual(await requeueDispatched({ api: plain, repository: REPO, number: 2208, headSha: HEAD, logger: silent }), { action: "requeue", enqueueMethod: "graphql" }); + assert.equal(enqueueMutations(plain).length, 1); + + const stacked = fakeApi({ live: stackedLive }); + const stackedResult = await requeueDispatched({ api: stacked, repository: REPO, number: 2123, headSha: HEAD, logger: silent, sleep: immediateSleep }); + assert.equal(stackedResult.enqueueMethod, "merge-async"); + assert.equal(enqueueMutations(stacked).length, 0); + + for (const [live, why] of [ + [{ ...openLive, state: "MERGED" }, "MERGED"], + [{ ...openLive, mergeQueueEntry: { state: "QUEUED", position: 1 } }, "already-queued"], + [{ ...openLive, headRefOid: "f".repeat(40) }, "head-changed"], + [stackUpperLive, "stack-upper"], + ]) { + const api = fakeApi({ live }); + assert.deepEqual(await requeueDispatched({ api, repository: REPO, number: 2208, headSha: HEAD, logger: silent }), { action: "none", why }); + assert.equal(enqueueMutations(api).length, 0, why); + assert.equal(mergeAsyncCalls(api).length, 0, why); + assert.equal(commentPosts(api).length, 0, "판정·코멘트는 방출 처리 job 몫이다"); + } +}); + +test("사람 토큰이 없으면 GITHUB_TOKEN 으로 넣지 않고 직접 투입 명령을 안내한다", () => { + const body = renderMissingQueueTokenComment({ headSha: HEAD, repository: REPO, number: 2208 }); + assert.match(body, /MERGE_QUEUE_TOKEN/); + assert.match(body, /CI_TIMEOUT/); + assert.match(body, /gh pr merge 2208 --repo Afternote\/Afternote-FE/); + assert.ok(!body.includes("\nCloses #12", + user: { login: "dependabot[bot]", type: "Bot" }, + }), + repository: "Afternote/Afternote-FE", + loadIssue, + }); + assert.deepEqual(closing, { issues: [12], rejected: [] }); + const result = await validatePullRequestIssueLink({ pullRequest: pullRequest({ title: "chore(deps): bump upload-artifact (#12)", diff --git a/.github/scripts/validate-pr-issue-link.mjs b/.github/scripts/validate-pr-issue-link.mjs index 80c3c573ba..abd00791e4 100644 --- a/.github/scripts/validate-pr-issue-link.mjs +++ b/.github/scripts/validate-pr-issue-link.mjs @@ -123,7 +123,9 @@ export async function validatePullRequestIssueLink({ pullRequest, repository, lo // 대표 Issue 는 이 PR 이 끝내는 Issue 다. Refs 로 걸 수 있게 두면 두 가지 도피로가 열린다 — // 열린 blocked_by 가 있는 Issue 를 merge-order-guard 밖에서 머지하고 Issue 는 손으로 닫는 것, // Issue 의 일부만 하고 남는 몫을 본문 산문에만 남기는 것 (#1748). 일부만 한다면 그 몫을 새 Issue 로 - // 분리해 대표 Issue 로 삼는다. 봇 PR 은 사람이 나중에 링크를 붙이는 구조라 종전대로 Refs 를 허용한다. + // 분리해 대표 Issue 로 삼는다. 봇 PR 은 closing 키워드를 면제한다(금지가 아니다). 사람이 나중에 붙이는 + // 링크는 여러 봇 PR 이 공유하는 Issue 일 수 있어 Refs 를 허용하고, dependabot-pr-gate 가 PR 마다 만든 + // Issue 는 Closes 로 건다(#2274). const botAuthor = isBotAuthor(pullRequest.user, author); if (!botAuthor) { const closingReferences = extractClosingIssueNumbers(pullRequest.body, repository); diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 184af63238..7b92da1793 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -145,7 +145,7 @@ jobs: CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") printf '%s' "$pull_request" > "$pull_request_file" elif [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$PR_AUTHOR" = "dependabot[bot]" ]; then - # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Refs·CI Test Plan 을 채운다. + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Closes·CI Test Plan 을 채운다. # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). # 페이로드 제목에 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 # 때까지 live PR 을 기다렸다가 그 값으로 판정한다. quota 대기 60초와 합쳐도 job timeout From 691ba6ae41faabb7a976b47c91ef6399c964b66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 11:19:15 +0000 Subject: [PATCH 090/116] =?UTF-8?q?test(afternote):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EB=B0=94=EB=80=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8D=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4=20(#1?= =?UTF-8?q?694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 9b7e19d7d(#1617)가 AfternoteUpdatePayload 를 UpdateAfternoteInput 으로, MemorialPatchPayload 를 MemorialPatchInput 으로 바꿨는데 이 브랜치가 새로 추가한 테스트는 옛 이름을 써서 :feature:afternote:domain:compileTestKotlin 이 깨졌다. --- .../domain/usecase/editor/SaveAfternoteUseCaseTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt index 88bf8c8c20..fb33b54bd9 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt @@ -1,13 +1,13 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload import com.afternote.feature.afternote.domain.model.author.FieldPatch -import com.afternote.feature.afternote.domain.model.author.MemorialPatchPayload +import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository @@ -101,11 +101,11 @@ class SaveAfternoteUseCaseTest { fun `Update 는 id 와 payload 를 바꾸지 않고 update 로만 보낸다`() { val repository = FakeAfternoteRepository() val payload = - AfternoteUpdatePayload( + UpdateAfternoteInput( type = AfternoteType.MEMORIAL, title = "바뀐 제목", memorial = - MemorialPatchPayload( + MemorialPatchInput( memorialPhotoUrl = FieldPatch.Unchanged, songs = emptyList(), memorialVideo = FieldPatch.Set(null), From 1f40140488397b83b001ec625af8db0dd28a7aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 11:24:34 +0000 Subject: [PATCH 091/116] =?UTF-8?q?test(afternote):=20=EB=B0=94=EB=80=90?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20import=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EC=A0=95=EB=A0=AC=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt index fb33b54bd9..96dc4301f9 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/SaveAfternoteUseCaseTest.kt @@ -1,7 +1,6 @@ package com.afternote.feature.afternote.domain.usecase.editor import com.afternote.feature.afternote.domain.AfternoteType -import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload @@ -10,6 +9,7 @@ import com.afternote.feature.afternote.domain.model.author.FieldPatch import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand +import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking From 96c5522109f94a1259a450c7f36332b69fa94751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 11:27:14 +0000 Subject: [PATCH 092/116] =?UTF-8?q?docs(release):=20Play=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20GCP=20=EC=A4=80=EB=B9=84=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EA=B5=AC=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20(#852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/play-release.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/play-release.md b/docs/play-release.md index 2478622b24..0bc3476558 100644 --- a/docs/play-release.md +++ b/docs/play-release.md @@ -28,7 +28,7 @@ 잠긴 사유는 하나로 모인다. 앱 만들기 버튼의 안내가 "새 앱을 만들려면 계정 확인을 완료하세요" 이고, 패키지 이름 등록도 "먼저 홈페이지에서 처리되지 않은 인증을 완료해야 합니다" 라고 같은 곳을 가리킨다. -그래서 아래 "자동화 사전 준비" 는 1번(앱 등록)부터 대기 상태이고, 2~5번은 전부 1번에 매달려 있다. +그래서 아래 "자동화 사전 준비" 는 1번(앱 등록)부터 대기 상태이고, Play Console 칸인 2번과 4번은 1번에 매달려 있다. 3번(Google Cloud)은 Play Console 과 무관해 2026-10-05 에 먼저 끝냈다(#852). ### 지금 막혀 있는 것과 푸는 사람 @@ -175,10 +175,21 @@ Android Publisher API는 **Console에서 최소 한 번 수동 업로드된 앱* **3. Google Cloud — API와 서비스 계정** (`console.cloud.google.com`) -1. **API 및 서비스 → 라이브러리**에서 `Google Play Android Developer API`를 사용 설정한다. -2. **IAM 및 관리자 → 서비스 계정 → 서비스 계정 만들기**: 이름 예 `play-internal-publisher`. **프로젝트 IAM 역할은 주지 않는다** — Play 권한은 Play Console에서 별도로 준다. -3. Firebase 배포용 서비스 계정을 재사용하지 않는다. 두 채널의 자격을 분리해 두는 것이 이 이슈의 요구사항이다. -4. **IAM 및 관리자 → Workload Identity 제휴**에서 기존 provider를 재사용하되, 새 서비스 계정에 `roles/iam.workloadIdentityUser`를 부여하면서 principal을 이 저장소로 한정한다. +2026-10-05 에 끝냈다. Play Console 상태와 무관한 칸이라 1·2번을 기다리지 않았다. 아래는 그날 gcloud 로 확인한 프로젝트 `afternote-android`(번호 `918752702102`)의 구성이다([#852 코멘트](https://github.com/Afternote/Afternote-FE/issues/852#issuecomment-5993394780)). + +| 항목 | 상태 | +|---|---| +| `Google Play Android Developer API` | 사용 설정됨 | +| 서비스 계정 | `play-internal-publisher@afternote-android.iam.gserviceaccount.com`. 프로젝트 IAM 역할 없음. Play 권한은 4번에서 Play Console 이 준다 | +| WIF provider | `projects/918752702102/locations/global/workloadIdentityPools/github-actions/providers/afternote-fe` | +| provider attribute condition | `repository` 가 `Afternote/Afternote-FE`, `ref` 가 `develop`·`main`, `workflow_ref` 가 `firebase-wif-canary.yml`·`release-distribution.yml`·`release-play-internal.yml` 로 시작하는 것만 허용 | +| `play-internal-publisher` 의 `roles/iam.workloadIdentityUser` | principalSet `.../attribute.workflow_ref/Afternote/Afternote-FE/.github/workflows/release-play-internal.yml@refs/heads/main` 하나 | +| Firebase 계정 `afternote-ci` 의 같은 역할 | `release-distribution.yml@refs/heads/main`, `firebase-wif-canary.yml@refs/heads/main`, `firebase-wif-canary.yml@refs/heads/develop` 셋 | + +- Firebase 배포용 서비스 계정을 재사용하지 않는다. 두 채널의 자격 분리가 #852 의 요구사항이다. +- provider 는 Firebase 와 같은 것을 쓴다. 그래서 자격 분리는 provider 가 아니라 서비스 계정 바인딩이 맡는다. 바인딩을 저장소 전체(`attribute.repository`)로 걸면 provider 를 통과하는 다른 배포 워크플로도 그 계정을 빌릴 수 있다. 두 계정 모두 워크플로 단위(`attribute.workflow_ref`)로 묶은 이유다. +- provider 를 새로 만들거나 attribute condition 을 바꿀 때는 condition 의 `workflow_ref` 허용 목록과 서비스 계정별 워크플로 단위 바인딩을 함께 맞춘다. 한쪽만 바꾸면 인증이 거부되거나, 반대로 허용한 워크플로가 남의 계정까지 빌릴 수 있게 된다. 배포 워크플로 파일 이름을 바꿀 때도 같다. +- 시크릿에 넣을 비밀 아닌 값 두 개(`GCP_WORKLOAD_IDENTITY_PROVIDER`, `GCP_PLAY_SERVICE_ACCOUNT`)는 1Password 항목 `Afternote GCP Workload Identity`에 있다. **4. Play Console — 서비스 계정에 최소 권한 부여** @@ -216,7 +227,7 @@ environment 와 보호 규칙, 변수는 2026-09-06 에 만들어 두었다. 남 | `RELEASE_KEY_PASSWORD` | play-internal → Environment secrets | key 비밀번호 | | `PLAY_PACKAGE_NAME` | play-internal → Environment variables | `com.afternote.afternote_fe` | -`KAKAO_NATIVE_APP_KEY`·`GOOGLE_WEB_CLIENT_ID`·`GOOGLE_SERVICES_JSON_B64`는 이미 저장소 secret으로 있어 그대로 쓴다. keystore·비밀번호·서비스 계정 값은 담당자가 직접 입력하며 저장소나 문서에 넣지 않는다. +`KAKAO_NATIVE_APP_KEY`·`GOOGLE_WEB_CLIENT_ID`·`GOOGLE_SERVICES_JSON_B64`는 이미 저장소 secret으로 있어 그대로 쓴다. keystore·비밀번호는 담당자가 직접 입력하며 저장소나 문서에 넣지 않는다. `GCP_WORKLOAD_IDENTITY_PROVIDER`·`GCP_PLAY_SERVICE_ACCOUNT` 는 비밀이 아니고 실제 값은 3번과 1Password 항목 `Afternote GCP Workload Identity`에 있지만, secret 등록은 마찬가지로 손으로 한다. ### 실행 From 2a734d321120094e3b050f7cea931b42b767446c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 12:23:21 +0000 Subject: [PATCH 093/116] =?UTF-8?q?ci(platform):=20=EC=8A=B9=EC=9D=B8=200?= =?UTF-8?q?=EA=B1=B4=20=EB=A3=B0=EC=85=8B=EC=9D=98=20=EB=B9=88=20reviewDec?= =?UTF-8?q?ision=20=EB=8F=84=20Viewed=20=EC=9E=90=EB=8F=99=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=EB=8C=80=EC=83=81=EC=9C=BC=EB=A1=9C=20=EB=B3=B8?= =?UTF-8?q?=EB=8B=A4=20(#2277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit required-approval 룰셋을 승인 0건으로 내리면 GitHub 는 reviewDecision 을 null 로 돌려준다. 예약 판정은 승인 대기(REVIEW_REQUIRED)와 변경 요청(CHANGES_REQUESTED)만 막는다. --- .github/scripts/find-viewed-auto-merge-targets.mjs | 14 +++++++++----- .../find-viewed-auto-merge-targets.test.mjs | 9 +++++++-- .github/workflows/viewed-auto-merge.yml | 6 +++--- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index 0d6bbfe86f..7087f07f61 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -1,7 +1,7 @@ // Viewed 기반 자동 머지 대상 판정 (#2248) // -// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» -// 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 +// «Merge when ready» 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 // 한다. // // viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 @@ -17,6 +17,8 @@ import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +const BLOCKING_REVIEW_DECISIONS = new Set(["CHANGES_REQUESTED", "REVIEW_REQUIRED"]); + const PULL_REQUEST_FIELDS = ` id number isDraft headRefOid reviewDecision isInMergeQueue changedFiles author { login } @@ -47,8 +49,10 @@ export function decideViewedAutoMerge(pullRequest, files) { if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; } - if (pullRequest.reviewDecision !== "APPROVED") { - return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + // 승인 룰셋이 승인 0건을 요구하면 GitHub 는 reviewDecision 을 비워(null) 돌려준다. 막는 것은 + // 아직 승인이 필요하거나 변경 요청이 남은 상태뿐이다. + if (BLOCKING_REVIEW_DECISIONS.has(pullRequest.reviewDecision)) { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision}` }; } // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. if (files.length === 0 || files.length !== pullRequest.changedFiles) { @@ -58,7 +62,7 @@ export function decideViewedAutoMerge(pullRequest, files) { if (unviewed.length > 0) { return { enable: false, reason: `Viewed 안 된 파일 ${unviewed.length}/${files.length}` }; } - return { enable: true, reason: `모든 파일 Viewed·승인 — 자동 머지 예약` }; + return { enable: true, reason: `모든 파일 Viewed — 자동 머지 예약` }; } /** diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 6e8144dadb..ac2f0d749a 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -35,13 +35,18 @@ test("하나라도 Viewed 가 아니면 예약하지 않는다 — 체크 뒤 } }); -test("승인 상태가 아니면 Viewed 를 다 해도 예약하지 않는다", () => { - for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { +test("승인 대기·변경 요청이면 Viewed 를 다 해도 예약하지 않는다", () => { + for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED"]) { const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); assert.equal(decision.enable, false, String(reviewDecision)); } }); +test("승인을 요구하지 않는 룰셋(reviewDecision null)이면 Viewed 만으로 예약한다", () => { + const decision = decideViewedAutoMerge(pullRequest({ reviewDecision: null }), [viewed("a.kt"), viewed("b.kt")]); + assert.equal(decision.enable, true); +}); + test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않는다", () => { assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).enable, false); assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 0 }), []).enable, false); diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 29ef82f6fe..0c4c877e5d 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -2,9 +2,9 @@ # # 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약도 해제도 하지 않는다. # -# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. -# 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 -# 변경요청이 남으면 머지되지 않는다. +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 «Merge when ready» 를 +# 대신 건다. 승인 룰셋이 승인 0건이면 reviewDecision 이 비어 있어도 대상이다. 머지는 GitHub 가 룰셋·required +# check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. # # 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 VIEWED_BY 본인 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 # 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 From a6925ed46010ff72b85d3430eaac78520e9610af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 12:31:55 +0000 Subject: [PATCH 094/116] =?UTF-8?q?ci(platform):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=81=EC=B2=B4=20=EA=B0=80=EB=93=9C=C2=B7=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94=EC=B2=AD=C2=B7=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=EB=A6=AC=EB=B7=B0=20=ED=8C=90=EC=A0=95=20=EB=A6=AC?= =?UTF-8?q?=EC=BB=A8=EC=82=AC=EC=9D=BC=C2=B7awaiting-author=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4=20(#2?= =?UTF-8?q?277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 리뷰를 머지 조건에서 빼면서 리뷰를 전제로 돌던 자동화를 함께 지운다. 워크플로 4개(review-debt-guard, review-request-all, latest-review-decision-event·reconcile)와 그 스크립트·정책 테스트, PR Label Reconcile 의 awaiting-author 단계, Viewed 자동 머지의 리컨사일 트리거를 뺀다. README 의 코드 리뷰 절을 머지 절로 바꾼다. 필수 체크 guard 는 merge-order-guard 의 잡이라 그대로다. --- .../scripts/awaiting-author-policy.test.mjs | 176 ---- .github/scripts/check-author-debt.mjs | 77 -- .github/scripts/check-author-debt.test.mjs | 146 --- .github/scripts/ensure-api-quota.mjs | 7 +- .github/scripts/ensure-api-quota.test.mjs | 12 +- .github/scripts/label-awaiting-author-prs.mjs | 787 --------------- .../label-awaiting-author-prs.test.mjs | 939 ------------------ .../latest-review-decision-policy.test.mjs | 49 - .../reconcile-latest-review-decision.mjs | 308 ------ .../reconcile-latest-review-decision.test.mjs | 275 ----- .github/scripts/review-debt-policy.test.mjs | 249 ----- .github/scripts/review-debt-query.test.mjs | 62 -- .github/scripts/validate-pr-issue-link.mjs | 1 - .github/workflows/conflict-label.yml | 9 +- .../latest-review-decision-event.yml | 18 - .../latest-review-decision-reconcile.yml | 46 - .github/workflows/pr-assign-author.yml | 2 +- .github/workflows/review-debt-guard.yml | 428 -------- .github/workflows/review-request-all.yml | 206 ---- .github/workflows/viewed-auto-merge.yml | 6 +- README.md | 16 +- 21 files changed, 12 insertions(+), 3807 deletions(-) delete mode 100644 .github/scripts/awaiting-author-policy.test.mjs delete mode 100644 .github/scripts/check-author-debt.mjs delete mode 100644 .github/scripts/check-author-debt.test.mjs delete mode 100644 .github/scripts/label-awaiting-author-prs.mjs delete mode 100644 .github/scripts/label-awaiting-author-prs.test.mjs delete mode 100644 .github/scripts/latest-review-decision-policy.test.mjs delete mode 100644 .github/scripts/reconcile-latest-review-decision.mjs delete mode 100644 .github/scripts/reconcile-latest-review-decision.test.mjs delete mode 100644 .github/scripts/review-debt-policy.test.mjs delete mode 100644 .github/scripts/review-debt-query.test.mjs delete mode 100644 .github/workflows/latest-review-decision-event.yml delete mode 100644 .github/workflows/latest-review-decision-reconcile.yml delete mode 100644 .github/workflows/review-debt-guard.yml delete mode 100644 .github/workflows/review-request-all.yml diff --git a/.github/scripts/awaiting-author-policy.test.mjs b/.github/scripts/awaiting-author-policy.test.mjs deleted file mode 100644 index 1e4b7103e2..0000000000 --- a/.github/scripts/awaiting-author-policy.test.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { - judgeAwaitingAuthor, - parseReviewGateExemptAuthors, - planAwaitingAuthorLabels, -} from "./label-awaiting-author-prs.mjs"; - -const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); -const reconcile = await readFile(new URL("../workflows/conflict-label.yml", import.meta.url), "utf8"); -const script = await readFile(new URL("./label-awaiting-author-prs.mjs", import.meta.url), "utf8"); -const authorDebtCheck = await readFile(new URL("./check-author-debt.mjs", import.meta.url), "utf8"); -const exemptAuthors = parseReviewGateExemptAuthors(guard); - -test("가드는 작성자 무조치를 공통 판정 CLI 로 검사한다", () => { - // 라벨은 주기 리컨사일 결과라 입장 시점에 스테일할 수 있다. 가드는 live PR 목록을 읽는 - // CLI 를 호출하고, CLI 는 라벨 리컨사일러와 같은 judgeAwaitingAuthor 를 재사용해야 한다. - assert.match(guard, /node \.github\/scripts\/check-author-debt\.mjs/); - assert.match(guard, /AUTHOR_DEBT_FILE="\$author_debt_file"/); - assert.match(authorDebtCheck, /findAuthorDebts/); - assert.match(script, /export function findAuthorDebts/); -}); - -test("라벨 판정은 입장 가드에 선언된 작성자 면제 목록을 직접 읽는다", () => { - assert.match(guard, /^\s*REVIEW_GATE_EXEMPT_AUTHORS:/m); - assert.match(guard, /\$REVIEW_GATE_EXEMPT_AUTHORS/); - assert.match(script, /new URL\("\.\.\/workflows\/review-debt-guard\.yml", import\.meta\.url\)/); - assert.doesNotMatch(script, /review-gate-policy\.json/); - const mainAt = script.indexOf("async function main()"); - assert.ok(mainAt > 0); - const main = script.slice(mainAt); - assert.match(main, /parseReviewGateExemptAuthors\(/); - assert.match(main, /planAwaitingAuthorLabels\(\{[^}]*\bexemptAuthors\b/); -}); - -test("workflow 파일이 없는 가드 checkout에서도 작성자 빚 판정을 import할 수 있다", async () => { - // data URL 에는 상대 workflow 경로가 없다. 모듈을 불러오는 순간 목록을 읽으면 - // scripts 만 checkout 하는 오래된 가드와 마찬가지로 import가 실패한다. - const module = await import(`data:text/javascript;base64,${Buffer.from(script).toString("base64")}`); - - assert.equal(typeof module.findAuthorDebts, "function"); - assert.equal(typeof module.judgeAwaitingAuthor, "function"); -}); - -test("가드의 면제 목록에 있는 작성자는 라벨 계획에서도 빠진다", () => { - const pullRequests = exemptAuthors.map((login, index) => ({ - number: index + 1, - isDraft: false, - author: { login: login.toUpperCase() }, - labels: { nodes: [{ name: "awaiting-author" }] }, - reviews: { - nodes: [{ - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }], - }, - })); - const plan = planAwaitingAuthorLabels({ pullRequests, exemptAuthors }); - - assert.deepEqual(plan.toLabel, []); - assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), pullRequests.map(({ number }) => number)); - assert.ok(plan.toUnlabel.every(({ reason }) => reason === "리뷰 게이트 면제 작성자")); -}); - -test("리뷰어 빚과 작성자 빚은 한 번의 안내와 종료로 합친다", () => { - // 두 경로가 각각 댓글·close 를 수행하면 같은 opened 이벤트에서 중복 코멘트와 경합이 - // 생긴다. 두 TSV 를 모두 만든 뒤 한 번만 최종 결정을 내려야 한다. - assert.match(guard, /\[ ! -s "\$debt_file" \] && \[ ! -s "\$author_debt_file" \]/); - assert.equal((guard.match(/gh pr close/g) ?? []).length, 1); - assert.equal((guard.match(/> \/tmp\/debt-comment\.md/g) ?? []).length, 1); -}); - -test("두 판정은 «PR 전체의 최신 결정 리뷰» 라는 같은 축을 쓴다", () => { - // 한쪽만 리뷰어별로 묶으면, 승인으로 끝난 PR 에 더 오래된 변경요청 때문에 라벨이 - // 붙거나 그 반대가 된다. - assert.match(guard, /sort_by\(\.t\) \| last/); - assert.doesNotMatch(guard, /group_by\(\.u/); - assert.match(script, /export function latestDecision/); - assert.doesNotMatch(script, /groupBy/); -}); - -test("두 판정 모두 병합 커밋을 작성자 반영으로 세지 않는다", () => { - // 병합 커밋의 diff 는 base 에서 들어온 변경을 통째로 담아 반영분과 구별되지 않는다. - assert.match(guard, /\(\.parents \| length\) < 2/); - assert.match(script, /parents\?\.totalCount \?\? 0\) >= 2/); -}); - -test("두 판정 모두 빈 커밋을 반영으로 세지 않는다", () => { - // 리뷰어가 미는 +0/-0 CI 재트리거 커밋이 «작성자가 반영했다» 가 되면, 방치된 PR 의 - // 라벨이 조용히 떨어진다 (#1459). - assert.match(guard, /\(\.files \/\/ \[\]\) \| length/); - assert.match(guard, /\[ "\$changed" -gt 0 \] \|\| continue/); - assert.match(script, /changedFilesIfAvailable/); - assert.match(script, /changed === 0/); -}); - -test("두 판정 모두 커밋 작성자를 확인하고 이메일 폴백을 쓴다", () => { - // 누가 올렸는지를 보지 않으면 리뷰어 커밋이 작성자 반영으로 둔갑한다. 계정이 연결되지 - // 않은 커밋은 login 이 비어 같은 PR 의 작성자 이메일로만 가릴 수 있다. - assert.match(guard, /\(\.author\.login \/\/ ""\)/); - assert.match(guard, /email in own/); - assert.match(script, /ownEmails/); -}); - -test("두 판정 모두 커밋과 함께 작성자의 응답을 본다", () => { - // 커밋만 세면 base 를 merge 로 끌어와 반영한 PR 이 «무조치» 가 된다 (#1316 → #1450). - assert.match(guard, /issues\/\$pn\/comments/); - assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); - assert.match(script, /export function countAuthorResponses/); - assert.match(script, /fixes > 0 \|\| responses > 0/); -}); - -test("두 판정 모두 변경요청 뒤 작성자의 PR 본문 편집을 조치로 본다", () => { - // CI Test Plan 처럼 PR 본문 자체가 리뷰 대상인 지적은 새 커밋 없이 해결된다. 일반 - // updatedAt 이 아니라 편집자와 편집 시각이 남는 userContentEdits 를 사용해야 한다. - assert.match(guard, /userContentEdits/); - assert.match(script, /userContentEdits\(last: 50\)/); - assert.match(script, /export function countAuthorBodyEdits/); - assert.match(script, /bodyEdits > 0/); -}); - -test("두 판정 모두 draft·봇·fork 를 대상에서 뺀다", () => { - assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); - assert.match(guard, /select\(\.draft == false\)/); - assert.match(guard, /봇 PR — 건너뜀/); - - for (const [name, pullRequest] of [ - ["draft", { isDraft: true }], - ["bot", { author: { login: "dependabot[bot]" } }], - ["fork", { headRepository: { nameWithOwner: "someone/fork" } }], - ]) { - const verdict = judgeAwaitingAuthor( - { - isDraft: false, - author: { login: "author" }, - headRepository: { nameWithOwner: "Afternote/Afternote-FE" }, - reviews: { - nodes: [ - { - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "r" }, - }, - ], - }, - commits: { nodes: [] }, - comments: { nodes: [] }, - ...pullRequest, - }, - { repository: "Afternote/Afternote-FE" }, - ); - assert.equal(verdict.awaiting, false, `${name} 은 대상이 아니어야 한다`); - } -}); - -test("라벨은 리컨사일러로만 관리된다 — 붙이는 경로와 떼는 경로가 갈라지지 않는다", () => { - // 이벤트로 붙이고 다른 이벤트로 떼면 떼는 쪽이 새고 스테일 라벨이 남는다. 한 번의 - // 차이 계산으로 양쪽을 처리하는 구조를 강제한다. - assert.match(script, /export function planAwaitingAuthorLabels/); - assert.match(script, /toUnlabel/); - - // 전용 워크플로를 새로 세우지 않는다. 트리거·concurrency·quota 처리가 이미 허브에 있다. - assert.match(reconcile, /label-awaiting-author-prs\.test\.mjs/); - assert.match(reconcile, /node \.github\/scripts\/label-awaiting-author-prs\.mjs/); - assert.match(reconcile, /awaiting-author-policy\.test\.mjs/); -}); - -test("리컨사일러 실패가 다른 리컨사일러를 가리지 않는다", () => { - // 허브는 실패를 모아 마지막에 exit 한다. `&&` 로 이으면 앞이 죽을 때 뒤가 아예 돌지 않는다. - assert.match(reconcile, /node \.github\/scripts\/label-awaiting-author-prs\.mjs \|\| failures=1/); -}); diff --git a/.github/scripts/check-author-debt.mjs b/.github/scripts/check-author-debt.mjs deleted file mode 100644 index 954394d4da..0000000000 --- a/.github/scripts/check-author-debt.mjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node - -// 새 PR 을 여는 사람이 변경요청을 받은 «자기가 담당하는» PR 을 방치하고 있는지 live 상태로 -// 검사한다. 담당은 어사인이고 어사인이 비면 작성자다(#1974) — AUTHOR 는 여는 사람의 로그인이지 -// «작성자 필터» 가 아니다. -// 라벨은 스테일할 수 있으므로 입장 판정에 사용하지 않는다. 열린 PR 을 한 번 조회한 뒤 -// label-awaiting-author-prs.mjs 의 SSOT 판정을 재사용하고, 워크플로가 댓글과 close 를 한 번만 -// 처리할 수 있도록 결과만 TSV 로 넘긴다. - -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -import { - createApi, - fetchOpenPullRequestsByAuthor, - findAuthorDebts, -} from "./label-awaiting-author-prs.mjs"; - -function requiredEnv(env, name) { - const value = env[name]; - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`${name} 가 필요합니다.`); - } - return value; -} - -function safeTsvField(value) { - return String(value ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); -} - -export function renderAuthorDebtTsv(debts) { - if (!Array.isArray(debts)) { - throw new TypeError("debts 배열이 필요합니다."); - } - - const lines = debts.map((debt) => - [debt.number, debt.createdDate, debt.reviewer, debt.title] - .map(safeTsvField) - .join("\t"), - ); - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -export async function runAuthorDebtCheck({ env = process.env, api, logger = console } = {}) { - const token = requiredEnv(env, "GITHUB_TOKEN"); - const repository = requiredEnv(env, "GITHUB_REPOSITORY"); - const author = requiredEnv(env, "AUTHOR"); - const currentPullRequestNumber = requiredEnv(env, "CURRENT_PULL_REQUEST_NUMBER"); - const outputFile = requiredEnv(env, "AUTHOR_DEBT_FILE"); - - const pullRequests = await fetchOpenPullRequestsByAuthor( - api ?? createApi(token), - repository, - author, - ); - const debts = findAuthorDebts({ - pullRequests, - repository, - author, - currentPullRequestNumber, - }); - - // 빚이 없어도 파일을 새로 써서 이전 실행의 결과가 남지 않게 한다. - await writeFile(outputFile, renderAuthorDebtTsv(debts), "utf8"); - logger.log(`작성자 미조치 PR ${debts.length}건`); - return debts; -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; -if (import.meta.url === invokedPath) { - runAuthorDebtCheck().catch((error) => { - console.error(error.message); - process.exitCode = 1; - }); -} diff --git a/.github/scripts/check-author-debt.test.mjs b/.github/scripts/check-author-debt.test.mjs deleted file mode 100644 index 12e062a644..0000000000 --- a/.github/scripts/check-author-debt.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; - -import { renderAuthorDebtTsv, runAuthorDebtCheck } from "./check-author-debt.mjs"; - -const REPO = "Afternote/Afternote-FE"; -const silent = { log() {} }; - -function changesRequested() { - return { - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }; -} - -function pullRequest(overrides = {}) { - return { - number: 10, - title: "fix(core): review debt", - isDraft: false, - createdAt: "2026-08-01T00:00:00Z", - author: { login: "author" }, - headRepository: { nameWithOwner: REPO }, - labels: { nodes: [] }, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [changesRequested()] }, - commits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - comments: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - userContentEdits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - ...overrides, - }; -} - -function graphqlApi(pullRequests) { - return async (apiPath, options = {}) => { - assert.equal(apiPath, "/graphql"); - assert.equal(options.method, "POST"); - // 담당은 어사인이 정본이고 어사인이 비면 작성자라 두 축을 모두 검색한다(#1974). - const query = options.body.variables.searchQuery; - assert.ok( - query === "repo:Afternote/Afternote-FE is:pr is:open assignee:author" || - query === "repo:Afternote/Afternote-FE is:pr is:open author:author", - query, - ); - if (query.endsWith("author:author")) { - return { - data: { - search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - }, - }; - } - return { - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: pullRequests, - }, - }, - }; - }; -} - -function environment(outputFile, overrides = {}) { - return { - GITHUB_TOKEN: "test-token", - GITHUB_REPOSITORY: REPO, - AUTHOR: "author", - CURRENT_PULL_REQUEST_NUMBER: "99", - AUTHOR_DEBT_FILE: outputFile, - ...overrides, - }; -} - -test("CLI 는 live GraphQL 결과를 판정해 안전한 4열 TSV 를 쓴다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - const debts = await runAuthorDebtCheck({ - env: environment(outputFile), - api: graphqlApi([ - pullRequest({ title: "fix(core): tab\tand\nnewline" }), - pullRequest({ number: 99, title: "현재 PR" }), - ]), - logger: silent, - }); - - assert.equal(debts.length, 1); - assert.equal( - await readFile(outputFile, "utf8"), - "10\t2026-08-01\treviewer\tfix(core): tab and newline\n", - ); -}); - -test("CLI 는 빚이 없어도 빈 결과 파일을 새로 쓴다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-empty-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - await runAuthorDebtCheck({ - env: environment(outputFile), - api: graphqlApi([]), - logger: silent, - }); - - assert.equal(await readFile(outputFile, "utf8"), ""); -}); - -test("CLI 는 API 실패와 불완전한 GraphQL 응답을 빚 없음으로 통과시키지 않는다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-fail-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - await assert.rejects( - runAuthorDebtCheck({ - env: environment(outputFile), - api: async () => { - throw new Error("API unavailable"); - }, - logger: silent, - }), - /API unavailable/, - ); - - await assert.rejects( - runAuthorDebtCheck({ - env: environment(outputFile), - api: async () => ({ data: { search: null } }), - logger: silent, - }), - /작성자의 열린 PR 검색 결과가 없습니다/, - ); -}); - -test("TSV 렌더러는 행과 열을 깨는 제어 문자를 제거한다", () => { - assert.equal( - renderAuthorDebtTsv([ - { number: 7, createdDate: "2026-08-31", reviewer: "r\t1", title: "a\rb\nc" }, - ]), - "7\t2026-08-31\tr 1\ta b c\n", - ); -}); diff --git a/.github/scripts/ensure-api-quota.mjs b/.github/scripts/ensure-api-quota.mjs index 7cbec400e9..bb21575184 100644 --- a/.github/scripts/ensure-api-quota.mjs +++ b/.github/scripts/ensure-api-quota.mjs @@ -4,8 +4,7 @@ // 구분한다 (#1465). // // 이 저장소의 게이트들은 잡마다 `gh api` 를 여러 번 부른다 — merge-order-guard 는 closing -// 이슈마다 blocked_by 를, review-debt-guard 는 열린 PR 전수를, impact 분류는 PR 메타를 -// 조회한다. PR 수 × 잡 수 × 잡당 호출수가 곱해져 installation 한도를 넘기면, 저장소 전체의 +// 이슈마다 blocked_by 를, impact 분류는 PR 메타를 조회한다. PR 수 × 잡 수 × 잡당 호출수가 곱해져 installation 한도를 넘기면, 저장소 전체의 // 게이트가 코드와 무관하게 빨갛게 된다. 그때 재실행은 남은 quota 를 더 태울 뿐이다. // // 명령: @@ -18,8 +17,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import process from "node:process"; -// 게이트 한 번이 쓰는 호출 수의 상한을 여유 있게 잡은 값. merge-order-guard 8 · review-debt-guard 6 -// (열린 PR 전수를 도는 루프 포함) 를 감안한다. +// 게이트 한 번이 쓰는 호출 수의 상한을 여유 있게 잡은 값. merge-order-guard 8 을 +// 감안한다. const DEFAULT_MIN_REMAINING = 200; // job timeout(대개 5분) 안에서 기다릴 수 있는 상한. 리셋이 이보다 멀면 기다리지 않고 실패한다. const DEFAULT_MAX_WAIT_SECONDS = 240; diff --git a/.github/scripts/ensure-api-quota.test.mjs b/.github/scripts/ensure-api-quota.test.mjs index 8d112266c1..4e5cf578cc 100644 --- a/.github/scripts/ensure-api-quota.test.mjs +++ b/.github/scripts/ensure-api-quota.test.mjs @@ -102,7 +102,7 @@ test("요약에 남은 호출과 리셋까지 남은 시간이 드러난다", () }); // 스크립트가 default branch 사본으로 도는 게이트. 판정 자체를 하는 자리라 PR 사본을 믿지 않는다. -const TRUSTED_COPY_GATES = ["merge-order-guard.yml", "review-debt-guard.yml"]; +const TRUSTED_COPY_GATES = ["merge-order-guard.yml"]; // PR 사본으로 도는 게이트. 이미 PR 사본의 분류·검증 스크립트를 실행하는 자리라 새 신뢰 경계가 // 열리지 않고, quota 스텝은 관측·대기라 무력화해도 통과시킬 판정이 없다. const PULL_REQUEST_COPY_GATES = ["repository-quality.yml", "codeql.yml"]; @@ -128,16 +128,6 @@ test("게이트 워크플로가 quota 확인과 실패 분류를 모두 건다", } }); -test("GraphQL 을 사용하는 리뷰 적체 가드는 core 와 graphql 을 모두 예산·분류한다", async () => { - const source = await workflow("review-debt-guard.yml"); - for (const resource of ["core", "graphql"]) { - assert.match( - source, - new RegExp(`ensure-api-quota\\.mjs ensure --resource ${resource} --max-wait 115`), - ); - assert.match(source, new RegExp(`ensure-api-quota\\.mjs classify --resource ${resource}`)); - } -}); test("default branch 사본으로 도는 게이트만 부트스트랩 예외를 갖는다", async () => { for (const name of TRUSTED_COPY_GATES) { diff --git a/.github/scripts/label-awaiting-author-prs.mjs b/.github/scripts/label-awaiting-author-prs.mjs deleted file mode 100644 index 05aaac2785..0000000000 --- a/.github/scripts/label-awaiting-author-prs.mjs +++ /dev/null @@ -1,787 +0,0 @@ -#!/usr/bin/env node - -// 변경요청을 받은 뒤 작성자가 아무 조치도 하지 않은 PR 에 라벨을 붙인다 (#1552). -// -// `review-debt-guard.yml` 은 리뷰어의 빚과 작성자의 빚을 서로 다른 입장 조건으로 다룬다. -// 리뷰어 빚 판정에서는 "재요청이 없거나 최신 변경요청 뒤 작성자 조치가 없으면 리뷰어 빚이 -// 아니다"(#1136)를 유지한다. 대신 `check-author-debt.mjs` 가 이 파일의 같은 판정을 재사용해, -// 공이 작성자에게 남은 자기 PR 이 있으면 새 PR 을 닫는다. 라벨 리컨사일러는 현재 상태를 열린 -// PR 목록에서 바로 볼 수 있게 만드는 관찰 가능성 경로이고, check CLI 는 새 PR 입장 게이트다. -// -// 8/30 실측: 열린 non-draft 29건 중 최신 판정이 CHANGES_REQUESTED 인 9건이 9건 모두 작성자 -// 무조치였고, 그중 #440 은 49일 · #767·#771 은 22일째였다. 오래 열려 있을수록 develop 이 -// 전진해 충돌 비용이 커진다. -// -// 이벤트가 아니라 리컨사일러인 이유. 붙이는 경로와 떼는 경로를 따로 만들면 떼는 쪽이 새고 -// 스테일 라벨이 남아 신호가 죽는다. 여기서는 매 실행이 «지금 무조치인 PR 집합» 을 다시 계산해 -// 차이만 쓰므로 붙이기와 떼기가 갈라질 수 없다. 호출도 PR 수만큼 곱해지지 않는다 (#1465). -// -// 판정 기준은 `review-debt-guard.yml` 과 같아야 한다. 한쪽만 바뀌면 가드가 «작성자 몫» 이라고 -// 판단한 PR 에 라벨이 없거나, 반대로 리뷰어 몫인 PR 에 라벨이 붙는다. 커밋·응답·본문 편집을 -// 포함해 두 판정이 공유하는 술어는 `awaiting-author-policy.test.mjs` 가 잠근다. - -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -export const DEFAULT_LABEL = "awaiting-author"; - -const LABEL_COLOR = "FBCA04"; -const LABEL_DESCRIPTION = "변경요청 뒤 작성자 조치 없음 — 공은 작성자에게 있다"; -// 25건 × (리뷰·커밋·코멘트·본문 편집 100) 를 한 번에 물으면 GraphQL 이 시간 안에 못 돌려 -// 504 를 낸다 -// (8/30 실측). 페이지를 잘게 끊고, 하위 컬렉션도 «판정 이후» 를 덮을 만큼만 가져온다. 모두 -// `last` 인 것은 필요한 쪽이 최근이기 때문이다 — 판정은 최신 결정 리뷰이고, 반영·응답은 그 -// 시각 이후만 센다. `first` 로 앞부분을 가져오면 정작 볼 구간이 잘린다. -const PULL_REQUEST_PAGE_SIZE = 10; - -const OPEN_PULL_REQUESTS_QUERY = ` -query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { - repository(owner: $owner, name: $name) { - pullRequests(states: OPEN, first: $pageSize, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - number - title - isDraft - createdAt - author { login } - assignees(first: 10) { nodes { login } } - headRepository { nameWithOwner } - labels(first: 50) { nodes { name } } - reviews(last: 50) { - pageInfo { hasPreviousPage } - nodes { - state - submittedAt - authorCanPushToRepository - author { login } - } - } - commits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - commit { - committedDate - changedFilesIfAvailable - parents(first: 2) { totalCount } - author { email user { login } } - } - } - } - comments(last: 50) { - pageInfo { hasPreviousPage } - nodes { - createdAt - author { login } - } - } - userContentEdits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - editedAt - editor { login } - } - } - } - } - } -}`; - -const OPEN_PULL_REQUESTS_BY_AUTHOR_QUERY = ` -query($searchQuery: String!, $cursor: String, $pageSize: Int!) { - search(query: $searchQuery, type: ISSUE, first: $pageSize, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - ... on PullRequest { - number - title - isDraft - createdAt - author { login } - assignees(first: 10) { nodes { login } } - headRepository { nameWithOwner } - labels(first: 50) { nodes { name } } - reviews(last: 50) { - pageInfo { hasPreviousPage } - nodes { - state - submittedAt - authorCanPushToRepository - author { login } - } - } - commits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - commit { - committedDate - changedFilesIfAvailable - parents(first: 2) { totalCount } - author { email user { login } } - } - } - } - comments(last: 50) { - pageInfo { hasPreviousPage } - nodes { - createdAt - author { login } - } - } - userContentEdits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - editedAt - editor { login } - } - } - } - } - } -}`; - -// 판정 함수가 각 connection 에서 읽는 시각 필드. reviews 는 판정 기준 시각 자체를 구하는 -// 근거라 여기 넣지 않는다. -const ACTIVITY_TIMESTAMP_BY_CONNECTION = Object.freeze({ - commits: (node) => node?.commit?.committedDate, - comments: (node) => node?.createdAt, - userContentEdits: (node) => node?.editedAt, -}); - -function sameLogin(a, b) { - return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); -} - -function isOneOf(login, owners) { - return (owners ?? []).some((owner) => sameLogin(login, owner)); -} - -function toOwners(author) { - return Array.isArray(author) ? author : [author]; -} - -/** - * PR 의 담당자 로그인 목록. - * - * PR 의 공은 «올린 사람» 이 아니라 «어사인된 사람» 에게 있다(#1974 결정, #1980). 변경요청에 - * 응답할 의무도, 그 미응답이 막는 «새 PR 열기» 도 담당자의 몫이다. 어사인이 비어 있는 동안만 - * 작성자가 담당자다 — pr-assign-author 가 빈 어사인에 작성자를 걸므로 잠깐 비는 창에서도 - * 결과가 같다. - */ -export function responsibleLogins(pullRequest) { - const assignees = (pullRequest?.assignees?.nodes ?? []) - .map((node) => node?.login) - .filter((login) => typeof login === "string" && login !== ""); - if (assignees.length > 0) { - return assignees; - } - const author = pullRequest?.author?.login; - return typeof author === "string" && author !== "" ? [author] : []; -} - -/** - * PR 전체에서 «최신 결정 리뷰» 를 고른다. - * - * 쓰기 권한이 있는 팀원의 판정만 인정하고 리뷰어별로 묶지 않는다. 외부인의 변경요청으로 팀원의 - * 새 PR 을 막거나, 외부인의 승인으로 팀 변경요청을 지울 수 없다. 팀에서 한 사람이 더 늦은 판정을 - * 내렸다면 그것이 PR 의 현재 상태이고, 더 오래된 다른 팀원의 변경요청을 다시 살리지 않는다. - */ -export function latestDecision(reviews) { - const decisions = (reviews ?? []) - .filter((review) => review?.state === "APPROVED" || review?.state === "CHANGES_REQUESTED") - .filter((review) => review?.authorCanPushToRepository === true) - .filter((review) => typeof review.submittedAt === "string") - .sort((a, b) => (a.submittedAt < b.submittedAt ? -1 : a.submittedAt > b.submittedAt ? 1 : 0)); - - return decisions.length > 0 ? decisions[decisions.length - 1] : null; -} - -/** - * `last: 50` 이 판정에 필요한 범위를 덮는지 본다. - * - * 판정은 최신 결정 시각 이후 항목만 세므로, 가져온 것 중 가장 오래된 항목이 그 시각보다 앞서면 - * 그 이후는 전부 들어와 있다. `reviews` 는 그 시각 자체를 구하는 근거라 여기서 제외한다. - */ -export function isCoveredSinceDecision(connectionName, nodes, since) { - const readTimestamp = ACTIVITY_TIMESTAMP_BY_CONNECTION[connectionName]; - if (!readTimestamp) { - return false; - } - // 최신 결정이 없거나 변경요청이 아니면 judgeAwaitingAuthor 가 이 connection 을 읽지 않는다. - if (since === null || since === undefined) { - return true; - } - if (!Array.isArray(nodes) || nodes.length === 0) { - return false; - } - const oldest = readTimestamp(nodes[0]); - return typeof oldest === "string" && oldest <= since; -} - -/** - * 판정 시각 이후 작성자가 올린 «실질» 커밋 수를 센다. - * - * 병합 커밋은 빼고(base 를 끌어온 변경이 반영분과 구별되지 않는다), 바뀐 파일이 0건인 커밋도 - * 뺀다. 누가 올렸는지를 보지 않으면 리뷰어가 미는 CI 재트리거 빈 커밋이 «작성자가 반영했다» 가 - * 되어 가짜 판정이 된다 — 8/28 에 그 한 건으로 PR 3건이 빚으로 잡혔다 (#1459). - * - * 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 작성자 것으로 확인된 - * 커밋의 이메일을 폴백 신원으로 쓴다. - * - * 변경 파일 수를 알 수 없으면(`changedFilesIfAvailable` 가 null) 반영으로 센다. 조회 공백을 - * «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다 — 놓치는 쪽이 잘못 붙이는 쪽보다 안전하다. - */ -export function countAuthorFixes({ commits, author, since }) { - const owners = toOwners(author); - const nodes = (commits ?? []).map((node) => node?.commit).filter(Boolean); - - const ownEmails = new Set(); - for (const commit of nodes) { - if (isOneOf(commit.author?.user?.login, owners) && commit.author?.email) { - ownEmails.add(commit.author.email.toLowerCase()); - } - } - - let fixes = 0; - for (const commit of nodes) { - if ((commit.parents?.totalCount ?? 0) >= 2) { - continue; - } - if (!(typeof commit.committedDate === "string" && commit.committedDate > since)) { - continue; - } - - const login = commit.author?.user?.login; - const email = commit.author?.email?.toLowerCase(); - const isAuthor = login ? isOneOf(login, owners) : Boolean(email && ownEmails.has(email)); - if (!isAuthor) { - continue; - } - - const changed = commit.changedFilesIfAvailable; - if (changed === 0) { - continue; - } - fixes += 1; - } - - return fixes; -} - -/** - * 판정 시각 이후 작성자가 남긴 응답 수를 센다. - * - * 커밋만 세면 base 를 merge 로 끌어와 반영한 PR 이 «무조치» 가 된다 — #1316 은 리뷰가 지목한 - * 파일이 실제로 바뀌고 응답 코멘트도 2건 달린 채 빚에서 빠져 있었다 (#1450). - * - * 라인 코멘트는 따로 조회하지 않는다. 작성자가 라인 코멘트를 남기면 그 코멘트를 담은 리뷰가 - * 함께 제출되므로 `reviews` 에서 잡힌다. PR 본문 편집은 별도 이력이므로 아래 - * `countAuthorBodyEdits` 에서 센다. - */ -export function countAuthorResponses({ comments, reviews, author, since }) { - const owners = toOwners(author); - const issueComments = (comments ?? []).filter( - (comment) => - isOneOf(comment?.author?.login, owners) && - typeof comment?.createdAt === "string" && - comment.createdAt > since, - ).length; - - const authorReviews = (reviews ?? []).filter( - (review) => - isOneOf(review?.author?.login, owners) && - typeof review?.submittedAt === "string" && - review.submittedAt > since, - ).length; - - return issueComments + authorReviews; -} - -/** - * 판정 시각 이후 PR 작성자가 직접 고친 본문 편집 수를 센다. - * - * PR 본문의 CI Test Plan 같이 커밋 밖에 있는 리뷰 지적은 본문 편집만으로 반영될 수 있다. - * `updatedAt` 은 라벨·리뷰어 등 다른 메타데이터 변경도 섞이므로 쓰지 않고, GitHub 가 남기는 - * `userContentEdits` 의 실제 편집자와 시각만 본다. 리뷰어의 대리 편집이나 편집자 정보가 사라진 - * 기록은 작성자 조치로 추정하지 않는다. - */ -export function countAuthorBodyEdits({ userContentEdits, author, since }) { - const owners = toOwners(author); - return (userContentEdits ?? []).filter( - (edit) => - isOneOf(edit?.editor?.login, owners) && - typeof edit?.editedAt === "string" && - edit.editedAt > since, - ).length; -} - -/** - * PR 한 건이 «변경요청 뒤 작성자 무조치» 인지 판정한다. - * - * 제외 축은 가드와 같다. draft 는 리뷰를 받을 상태가 아니고, 봇 PR 은 사람이 반영할 주체가 - * 없으며, fork PR 은 팀 리뷰 적체와 무관하다. - */ -export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { - const author = pullRequest?.author?.login ?? ""; - // 조치 주체는 담당자다. 어사인이 비어 있으면 작성자로 폴백한다. - const owners = responsibleLogins(pullRequest); - - if (pullRequest?.isDraft) { - return { awaiting: false, reason: "draft" }; - } - if (!author || owners.length === 0) { - return { awaiting: false, reason: "작성자 없음" }; - } - if (/\[bot\]$/i.test(author) || author === "dependabot" || author === "github-actions") { - return { awaiting: false, reason: "봇 PR" }; - } - - const headRepository = pullRequest?.headRepository?.nameWithOwner; - if (repository && headRepository && headRepository !== repository) { - return { awaiting: false, reason: `fork PR(${headRepository})` }; - } - - const decision = latestDecision(pullRequest?.reviews?.nodes); - if (!decision) { - return { awaiting: false, reason: "결정 리뷰 없음" }; - } - if (decision.state !== "CHANGES_REQUESTED") { - return { awaiting: false, reason: "최신 판정이 변경요청이 아님" }; - } - - const since = decision.submittedAt; - const fixes = countAuthorFixes({ - commits: pullRequest?.commits?.nodes, - author: owners, - since, - }); - const responses = countAuthorResponses({ - comments: pullRequest?.comments?.nodes, - reviews: pullRequest?.reviews?.nodes, - author: owners, - since, - }); - const bodyEdits = countAuthorBodyEdits({ - userContentEdits: pullRequest?.userContentEdits?.nodes, - author: owners, - since, - }); - - if (fixes > 0 || responses > 0 || bodyEdits > 0) { - return { - awaiting: false, - reason: `담당자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, - decidedAt: since, - owners, - }; - } - - return { - awaiting: true, - reason: "변경요청 뒤 담당자 조치 없음", - decidedAt: since, - reviewer: decision.author?.login ?? "", - owners, - }; -} - -/** - * 새 PR 작성자가 먼저 처리해야 할 다른 열린 PR 을 고른다. - * - * 열린 PR 목록은 호출자가 한 번 live 조회해 넘긴다. 현재 PR 은 이벤트 직후 그 목록에 이미 - * 포함되므로 번호로 명시적으로 제외한다. «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 - * 있으면 작성자)로 가른다. login 은 GitHub 의 대소문자 비구분 규약에 맞춰 비교하고, 실제 - * 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. - */ -export function findAuthorDebts({ pullRequests, repository, author, currentPullRequestNumber }) { - if (!Array.isArray(pullRequests)) { - throw new TypeError("pullRequests 배열이 필요합니다."); - } - if (typeof author !== "string" || author.trim() === "") { - throw new TypeError("author 가 필요합니다."); - } - - const currentNumber = Number(currentPullRequestNumber); - if (!Number.isSafeInteger(currentNumber) || currentNumber <= 0) { - throw new TypeError("currentPullRequestNumber 는 양의 정수여야 합니다."); - } - - const debts = []; - for (const pullRequest of pullRequests) { - if (Number(pullRequest?.number) === currentNumber) { - continue; - } - // 빚의 주인은 담당자다. 남이 올렸어도 내가 어사인된 PR 이면 내 빚이고, 내가 올렸어도 - // 남에게 어사인된 PR 이면 내 빚이 아니다. - if (!isOneOf(author, responsibleLogins(pullRequest))) { - continue; - } - - const verdict = judgeAwaitingAuthor(pullRequest, { repository }); - if (!verdict.awaiting) { - continue; - } - - debts.push({ - number: pullRequest.number, - createdDate: - typeof pullRequest.createdAt === "string" ? pullRequest.createdAt.slice(0, 10) : "", - reviewer: verdict.reviewer ?? "", - title: pullRequest.title ?? "", - }); - } - - return debts; -} - -/** - * 라벨을 붙일 PR 과 뗄 PR 을 한 번에 계산한다. - * - * 매 실행이 현재 집합을 다시 세우고 차이만 쓴다. 조치가 들어오면 다음 실행에서 라벨이 떨어지므로 - * 스테일 라벨이 남지 않는다. - */ -export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEFAULT_LABEL, exemptAuthors = [] }) { - const toLabel = []; - const toUnlabel = []; - const unchanged = []; - - for (const pullRequest of pullRequests ?? []) { - const labels = (pullRequest?.labels?.nodes ?? []).map((node) => node?.name); - const labeled = labels.includes(label); - // 면제는 담당자 기준이다. 담당자 전원이 면제 목록에 있을 때만 뺀다 — 면제자와 비면제자가 - // 함께 어사인된 PR 은 비면제자의 몫이 남아 있다. - const owners = responsibleLogins(pullRequest); - const exempt = owners.length > 0 && owners.every((login) => isOneOf(login, exemptAuthors)); - const verdict = exempt - ? { awaiting: false, reason: "리뷰 게이트 면제 작성자" } - : judgeAwaitingAuthor(pullRequest, { repository }); - const entry = { - number: pullRequest.number, - title: pullRequest.title, - author: owners.join(","), - reason: verdict.reason, - decidedAt: verdict.decidedAt, - reviewer: verdict.reviewer, - }; - - if (verdict.awaiting && !labeled) { - toLabel.push(entry); - } else if (!verdict.awaiting && labeled) { - toUnlabel.push(entry); - } else { - unchanged.push({ ...entry, labeled }); - } - } - - return { toLabel, toUnlabel, unchanged }; -} - -export function renderSummary({ plan, dryRun, label = DEFAULT_LABEL }) { - const lines = [`## \`${label}\` 리컨사일`]; - if (dryRun) { - lines.push("", "> DRY_RUN — 라벨을 쓰지 않고 계획만 출력합니다."); - } - - lines.push("", `- 붙임 ${plan.toLabel.length}건 · 뗌 ${plan.toUnlabel.length}건`); - - if (plan.toLabel.length > 0) { - lines.push("", "| PR | 담당자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); - for (const entry of plan.toLabel) { - lines.push( - `| #${entry.number} | @${entry.author} | ${entry.decidedAt?.slice(0, 10) ?? "-"} | @${entry.reviewer || "-"} |`, - ); - } - } - - if (plan.toUnlabel.length > 0) { - lines.push("", "**뗀 PR**", ""); - for (const entry of plan.toUnlabel) { - lines.push(`- #${entry.number} — ${entry.reason}`); - } - } - - return lines.join("\n"); -} - -export function createApi(token, { fetchImpl = globalThis.fetch } = {}) { - if (typeof fetchImpl !== "function") { - throw new TypeError("fetch 구현이 필요합니다."); - } - - return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { - const response = await fetchImpl(`https://api.github.com${apiPath}`, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "x-github-api-version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - - if (allowNotFound && response.status === 404) { - return null; - } - if (!response.ok) { - const detail = await response.text(); - throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); - } - if (response.status === 204) { - return null; - } - return response.json(); - }; -} - -async function graphql(api, query, variables) { - const payload = await api("/graphql", { method: "POST", body: { query, variables } }); - if (payload?.errors?.length) { - throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); - } - return payload.data; -} - -export async function fetchOpenPullRequests(api, repository) { - const [owner, name] = repository.split("/"); - if (!owner || !name || repository.split("/").length !== 2) { - throw new TypeError("repository 는 owner/name 형식이어야 합니다."); - } - - const pullRequests = []; - let cursor = null; - - for (;;) { - const data = await graphql(api, OPEN_PULL_REQUESTS_QUERY, { - owner, - name, - cursor, - pageSize: PULL_REQUEST_PAGE_SIZE, - }); - const page = data?.repository?.pullRequests; - if (!page) { - throw new Error("GraphQL 응답에 열린 PR 목록이 없습니다."); - } - if (!Array.isArray(page.nodes)) { - throw new Error("GraphQL 열린 PR nodes 가 배열이 아닙니다."); - } - pullRequests.push(...(page.nodes ?? []).filter(Boolean)); - if (!page.pageInfo?.hasNextPage) { - break; - } - cursor = page.pageInfo.endCursor; - if (!cursor) { - throw new Error("GraphQL 다음 페이지 cursor 가 없습니다."); - } - } - - return pullRequests; -} - -/** - * 입장 게이트용으로 한 사람이 담당하는 열린 PR 만 조회한다. - * - * 라벨 리컨사일러는 전체 열린 PR 이 필요하지만, 새 PR 입장마다 그 전체의 하위 리뷰·커밋· - * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 서버에서 먼저 제한한다. 담당은 어사인이 - * 정본이고 어사인이 비면 작성자이므로 `assignee:` 와 `author:` 두 검색을 합친다. 남에게 - * 어사인된 자기 작성 PR 은 `findAuthorDebts` 가 담당자 기준으로 걸러낸다. - */ -export async function fetchOpenPullRequestsByAuthor(api, repository, author) { - const [owner, name] = repository.split("/"); - if (!owner || !name || repository.split("/").length !== 2) { - throw new TypeError("repository 는 owner/name 형식이어야 합니다."); - } - if (typeof author !== "string" || author.trim() === "") { - throw new TypeError("author 가 필요합니다."); - } - - const pullRequests = []; - const seen = new Set(); - - for (const qualifier of ["assignee", "author"]) { - let cursor = null; - - for (;;) { - const data = await graphql(api, OPEN_PULL_REQUESTS_BY_AUTHOR_QUERY, { - searchQuery: `repo:${owner}/${name} is:pr is:open ${qualifier}:${author}`, - cursor, - pageSize: PULL_REQUEST_PAGE_SIZE, - }); - const page = data?.search; - if (!page) { - throw new Error("GraphQL 응답에 작성자의 열린 PR 검색 결과가 없습니다."); - } - if (!Array.isArray(page.nodes)) { - throw new Error("GraphQL 작성자 PR nodes 가 배열이 아닙니다."); - } - - for (const pullRequest of page.nodes) { - if (!pullRequest || !Number.isSafeInteger(pullRequest.number)) { - throw new Error("GraphQL 작성자 PR 검색 결과가 불완전합니다."); - } - if (seen.has(pullRequest.number)) { - continue; - } - seen.add(pullRequest.number); - - // `last: 50` 앞에 더 많은 항목이 있으면 최신 결정 시각 이후의 작성자 이메일·응답· - // 본문 편집을 완전하게 판정할 수 없다. 라벨은 다음 리컨사일에서 복구할 수 있지만, - // 입장 가드가 불완전한 근거로 PR 을 닫는 것은 되돌리기 비용이 있으므로 판정 자체를 - // 중단한다. - // - // 다만 완전성의 기준은 «전체 이력» 이 아니라 «판정이 실제로 읽는 범위» 다. 세 판정 - // 함수는 모두 최신 결정 시각(since) 이후 항목만 세고, judgeAwaitingAuthor 는 최신 - // 결정이 없거나 변경요청이 아니면 그 셋을 아예 부르지 않는다. `last: 50` 은 최신 - // 50건이므로 가져온 것 중 가장 오래된 항목이 since 보다 앞서면 since 이후는 전부 - // 확보한 것이고 판정은 완전하다 — 커밋이 몇백 건이든 상관없다. 이 구분이 없어서 - // 커밋 403건짜리 릴리스 PR(develop → main) 하나가 그 작성자의 새 PR 을 전부 막았다 - // (#1787). reviews 는 since 를 구하는 근거라 시각으로 대체 판정할 수 없어 그대로 둔다. - const decision = latestDecision(pullRequest.reviews?.nodes); - const since = decision?.state === "CHANGES_REQUESTED" ? decision.submittedAt : null; - for (const connectionName of ["reviews", "commits", "comments", "userContentEdits"]) { - const connection = pullRequest[connectionName]; - if (!connection || !Array.isArray(connection.nodes)) { - throw new Error(`GraphQL 작성자 PR ${connectionName} 응답이 불완전합니다.`); - } - if (connection.pageInfo?.hasPreviousPage === false) { - continue; - } - if (isCoveredSinceDecision(connectionName, connection.nodes, since)) { - continue; - } - throw new Error( - `GraphQL 작성자 PR ${connectionName} 최근 50건이 완전하지 않습니다.`, - ); - } - pullRequests.push(pullRequest); - } - - if (!page.pageInfo?.hasNextPage) { - break; - } - cursor = page.pageInfo.endCursor; - if (!cursor) { - throw new Error("GraphQL 작성자 PR 다음 페이지 cursor 가 없습니다."); - } - } - } - - return pullRequests; -} - -export async function ensureLabelExists(api, repository, label = DEFAULT_LABEL) { - const existing = await api(`/repos/${repository}/labels/${encodeURIComponent(label)}`, { - allowNotFound: true, - }); - if (existing) { - return; - } - await api(`/repos/${repository}/labels`, { - method: "POST", - body: { name: label, color: LABEL_COLOR, description: LABEL_DESCRIPTION }, - }); -} - -export async function applyPlan(api, repository, plan, { dryRun = false, label = DEFAULT_LABEL, logger = console } = {}) { - const failures = []; - - for (const entry of plan.toLabel) { - if (dryRun) { - logger.log(`(dry-run) #${entry.number} ${label} 부착`); - continue; - } - try { - await api(`/repos/${repository}/issues/${entry.number}/labels`, { - method: "POST", - body: { labels: [label] }, - }); - logger.log(`#${entry.number} ${label} 부착 — ${entry.reason}`); - } catch (error) { - failures.push(`#${entry.number} 라벨 부착 실패: ${error.message}`); - } - } - - for (const entry of plan.toUnlabel) { - if (dryRun) { - logger.log(`(dry-run) #${entry.number} ${label} 제거`); - continue; - } - try { - await api( - `/repos/${repository}/issues/${entry.number}/labels/${encodeURIComponent(label)}`, - { method: "DELETE", allowNotFound: true }, - ); - logger.log(`#${entry.number} ${label} 제거 — ${entry.reason}`); - } catch (error) { - failures.push(`#${entry.number} 라벨 제거 실패: ${error.message}`); - } - } - - return failures; -} - -/** 가드의 한 줄 환경변수 선언을 읽는다. 형식이 달라지면 빈 면제 목록으로 숨기지 않는다. */ -export function parseReviewGateExemptAuthors(workflow) { - const lines = workflow.split(/\r?\n/); - const declarations = lines.flatMap((line, index) => { - const match = /^([ \t]+)REVIEW_GATE_EXEMPT_AUTHORS:[ \t]*(.*)$/.exec(line); - return match ? [{ index, indent: match[1].length, value: match[2].trim() }] : []; - }); - if (declarations.length !== 1) { - throw new Error("REVIEW_GATE_EXEMPT_AUTHORS 선언이 정확히 한 개 필요합니다."); - } - const { index, indent, value } = declarations[0]; - const nextLine = lines.slice(index + 1).find((line) => line.trim() && !line.trimStart().startsWith("#")); - const continued = nextLine && /^[ \t]*/.exec(nextLine)[0].length > indent; - if (continued || !/^[a-zA-Z0-9-]+(?:[ \t]+[a-zA-Z0-9-]+)*$/.test(value)) { - throw new Error("REVIEW_GATE_EXEMPT_AUTHORS는 인용·주석 없는 한 줄의 공백 구분 로그인 목록이어야 합니다."); - } - return value.split(/\s+/).map((login) => login.toLowerCase()); -} - -async function main() { - const token = process.env.GITHUB_TOKEN; - const repository = process.env.GITHUB_REPOSITORY; - if (!token || !repository) { - throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); - } - - const label = process.env.AWAITING_AUTHOR_LABEL ?? DEFAULT_LABEL; - const dryRun = process.env.DRY_RUN === "true"; - const api = createApi(token); - - // 전체 checkout을 쓰는 라벨 CLI에서만 읽는다. 가드의 scripts-only checkout에서도 - // 상태 판정 함수를 import할 수 있어야 한다. 면제 목록의 정본은 기존 가드 선언이다. - const exemptAuthors = parseReviewGateExemptAuthors( - await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"), - ); - const pullRequests = await fetchOpenPullRequests(api, repository); - const plan = planAwaitingAuthorLabels({ pullRequests, repository, label, exemptAuthors }); - - if (!dryRun && plan.toLabel.length > 0) { - await ensureLabelExists(api, repository, label); - } - - const failures = await applyPlan(api, repository, plan, { dryRun, label }); - const summary = renderSummary({ plan, dryRun, label }); - console.log(summary); - - if (process.env.GITHUB_STEP_SUMMARY) { - const { appendFile } = await import("node:fs/promises"); - await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); - } - - if (failures.length > 0) { - throw new Error(failures.join("\n")); - } -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; -if (import.meta.url === invokedPath) { - main().catch((error) => { - console.error(error.message); - process.exitCode = 1; - }); -} diff --git a/.github/scripts/label-awaiting-author-prs.test.mjs b/.github/scripts/label-awaiting-author-prs.test.mjs deleted file mode 100644 index 10b4e9b437..0000000000 --- a/.github/scripts/label-awaiting-author-prs.test.mjs +++ /dev/null @@ -1,939 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - applyPlan, - countAuthorBodyEdits, - countAuthorFixes, - countAuthorResponses, - DEFAULT_LABEL, - ensureLabelExists, - fetchOpenPullRequests, - fetchOpenPullRequestsByAuthor, - findAuthorDebts, - judgeAwaitingAuthor, - latestDecision, - parseReviewGateExemptAuthors, - planAwaitingAuthorLabels, - renderSummary, - responsibleLogins, -} from "./label-awaiting-author-prs.mjs"; - -const REPO = "Afternote/Afternote-FE"; - -/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ -const silent = { log() {} }; - -test("게이트 면제 목록은 공백을 나누고 로그인 대소문자를 정규화한다", () => { - const workflow = [ - "jobs:", - " check:", - " env:", - " REPO: Afternote/Afternote-FE", - " REVIEW_GATE_EXEMPT_AUTHORS: KoOnGmAi Example-User ", - " run: echo check", - ].join("\r\n"); - - assert.deepEqual(parseReviewGateExemptAuthors(workflow), ["koongmai", "example-user"]); -}); - -test("게이트 면제 목록이 없거나 두 번 선언되면 빈 목록으로 넘기지 않는다", () => { - for (const workflow of [ - "env:\n REPO: Afternote/Afternote-FE\n", - "# REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n", - "env:\n REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n REVIEW_GATE_EXEMPT_AUTHORS: other\n", - ]) { - assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, workflow); - } -}); - -test("게이트 면제 목록은 따옴표·주석·배열·여러 줄과 잘못된 로그인 형식을 거부한다", () => { - for (const value of [ - "", - '"koongmai"', - "'koongmai'", - "koongmai # comment", - "[koongmai]", - "|\n koongmai", - ">\n koongmai", - "\n koongmai", - "koongmai\n other", - "koongmai,other", - "invalid_login", - ]) { - const workflow = `env:\n REVIEW_GATE_EXEMPT_AUTHORS: ${value}\n`; - assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, value); - } -}); - -function fakeApi({ responses = {}, failOn = null } = {}) { - const calls = []; - const api = async (apiPath, options = {}) => { - calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); - if (failOn && failOn(apiPath, options)) { - throw new Error("boom"); - } - for (const [pattern, value] of Object.entries(responses)) { - if (apiPath.startsWith(pattern)) { - return typeof value === "function" ? value(apiPath, options) : value; - } - } - return options.allowNotFound ? null : {}; - }; - api.calls = calls; - return api; -} - -function commit({ login = "author", email = "author@example.com", date = "2026-08-30T00:00:00Z", changed = 3, parents = 1 } = {}) { - return { - commit: { - committedDate: date, - changedFilesIfAvailable: changed, - parents: { totalCount: parents }, - author: { email, user: login === null ? null : { login } }, - }, - }; -} - -function pullRequest(overrides = {}) { - return { - number: 1, - title: "fix(core): 무언가", - isDraft: false, - createdAt: "2026-08-01T00:00:00Z", - author: { login: "author" }, - headRepository: { nameWithOwner: REPO }, - labels: { nodes: [] }, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - commits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - comments: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - userContentEdits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - ...overrides, - }; -} - -function changesRequested( - at = "2026-08-29T00:00:00Z", - reviewer = "reviewer", - authorCanPushToRepository = true, -) { - return { - state: "CHANGES_REQUESTED", - submittedAt: at, - authorCanPushToRepository, - author: { login: reviewer }, - }; -} - -test("최신 결정 리뷰는 리뷰어별로 묶지 않고 PR 전체에서 하나만 고른다", () => { - // 팀에서 한 사람이 더 늦은 판정을 내리면 그것이 PR 의 현재 상태다. 더 오래된 다른 - // 리뷰어의 변경요청을 다시 살리면 승인된 PR 에까지 라벨이 붙는다. - const decision = latestDecision([ - { - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-01T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "a" }, - }, - { - state: "APPROVED", - submittedAt: "2026-08-05T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "b" }, - }, - ]); - assert.equal(decision.state, "APPROVED"); - - // COMMENTED 는 판정이 아니다. - const onlyComments = latestDecision([ - { state: "COMMENTED", submittedAt: "2026-08-09T00:00:00Z", author: { login: "a" } }, - ]); - assert.equal(onlyComments, null); -}); - -test("변경요청 뒤 작성자가 아무것도 하지 않으면 대상이다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ reviews: { nodes: [changesRequested()] } }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, true); - assert.equal(verdict.reviewer, "reviewer"); - assert.equal(verdict.decidedAt, "2026-08-29T00:00:00Z"); -}); - -test("리뷰 게이트 면제 작성자는 대소문자와 무관하게 무조치 라벨 대상에서 빠진다", () => { - for (const login of ["koongmai", "KoOnGmAi"]) { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - author: { login }, - reviews: { nodes: [changesRequested()] }, - })], - }); - - assert.deepEqual(plan.toLabel, [], login); - assert.equal(plan.unchanged[0].reason, "리뷰 게이트 면제 작성자"); - } -}); - -test("면제 계정과 일부만 같은 작성자는 여전히 무조치 라벨 대상이다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - author: { login: "koongmai-other" }, - reviews: { nodes: [changesRequested()] }, - })], - }); - - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); -}); - -test("변경요청 리뷰어가 면제 계정이어도 다른 작성자의 무조치 라벨은 유지한다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "koongmai")] }, - })], - }); - - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); - assert.equal(plan.toLabel[0].reviewer, "koongmai"); -}); - -test("쓰기 권한이 없는 외부인의 변경요청은 작성자 빚을 만들지 못한다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [changesRequested("2026-08-29T00:00:00Z", "outsider", false)], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, false); - assert.equal(verdict.reason, "결정 리뷰 없음"); -}); - -test("쓰기 권한이 없는 외부인의 승인은 팀 변경요청을 지우지 못한다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [ - changesRequested("2026-08-28T00:00:00Z", "team-reviewer"), - { - state: "APPROVED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: false, - author: { login: "outsider" }, - }, - ], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, true); - assert.equal(verdict.reviewer, "team-reviewer"); - assert.equal(verdict.decidedAt, "2026-08-28T00:00:00Z"); -}); - -test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치인 것만 고른다", () => { - const debts = findAuthorDebts({ - repository: REPO, - author: "AuThOr", - currentPullRequestNumber: "20", - pullRequests: [ - // 현재 PR 자체는 아직 변경요청 상태여도 명시적으로 제외한다. - pullRequest({ number: 20, reviews: { nodes: [changesRequested()] } }), - // login 대소문자가 달라도 같은 작성자다. - pullRequest({ - number: 21, - title: "fix(core): 기존 지적 반영", - author: { login: "AUTHOR" }, - createdAt: "2026-08-17T10:00:00Z", - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "reviewer-1")] }, - }), - // 작성자가 이미 조치한 PR 은 입장 빚이 아니다. - pullRequest({ - number: 22, - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z" })] }, - }), - // 다른 작성자의 무조치 PR 은 이 작성자를 막지 않는다. - pullRequest({ - number: 23, - author: { login: "someone-else" }, - reviews: { nodes: [changesRequested()] }, - }), - // 남이 올렸어도 내게 어사인된 PR 은 내 빚이다(#1974). - pullRequest({ - number: 24, - title: "fix(home): 인계받은 PR", - author: { login: "someone-else" }, - assignees: { nodes: [{ login: "author" }] }, - createdAt: "2026-08-18T10:00:00Z", - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "reviewer-2")] }, - }), - // 내가 올렸어도 남에게 어사인된 PR 은 내 빚이 아니다. - pullRequest({ - number: 25, - assignees: { nodes: [{ login: "someone-else" }] }, - reviews: { nodes: [changesRequested()] }, - }), - ], - }); - - assert.deepEqual(debts, [ - { - number: 21, - createdDate: "2026-08-17", - reviewer: "reviewer-1", - title: "fix(core): 기존 지적 반영", - }, - { - number: 24, - createdDate: "2026-08-18", - reviewer: "reviewer-2", - title: "fix(home): 인계받은 PR", - }, - ]); -}); - -test("담당자는 어사인이고, 어사인이 비어 있을 때만 작성자다", () => { - assert.deepEqual(responsibleLogins(pullRequest()), ["author"]); - assert.deepEqual( - responsibleLogins(pullRequest({ assignees: { nodes: [{ login: "a" }, { login: "b" }] } })), - ["a", "b"], - ); - assert.deepEqual(responsibleLogins(pullRequest({ assignees: { nodes: [] } })), ["author"]); -}); - -test("변경요청 뒤 조치는 작성자가 아니라 담당자의 것만 센다", () => { - const base = { - assignees: { nodes: [{ login: "Assignee" }] }, - reviews: { nodes: [changesRequested()] }, - }; - // 작성자의 커밋·코멘트는 담당자가 따로 있으면 조치가 아니다. - const authorOnly = judgeAwaitingAuthor( - pullRequest({ - ...base, - commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z", login: "author" })] }, - comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "author" } }] }, - }), - { repository: REPO }, - ); - assert.equal(authorOnly.awaiting, true); - assert.deepEqual(authorOnly.owners, ["Assignee"]); - - // 담당자의 코멘트 하나면 풀린다. 대소문자는 가리지 않는다. - const assigneeAnswered = judgeAwaitingAuthor( - pullRequest({ - ...base, - comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "assignee" } }] }, - }), - { repository: REPO }, - ); - assert.equal(assigneeAnswered.awaiting, false); -}); - -test("면제는 담당자 전원이 면제일 때만 적용된다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["exempt"], - pullRequests: [ - // 면제 작성자가 올렸어도 비면제자에게 어사인됐으면 그 사람의 몫이다. - pullRequest({ - number: 40, - author: { login: "exempt" }, - assignees: { nodes: [{ login: "someone" }] }, - reviews: { nodes: [changesRequested()] }, - }), - // 비면제 작성자가 올렸어도 면제자에게 어사인됐으면 뺀다. - pullRequest({ - number: 41, - assignees: { nodes: [{ login: "EXEMPT" }] }, - reviews: { nodes: [changesRequested()] }, - }), - // 면제자와 비면제자가 함께 어사인되면 비면제자의 몫이 남는다. - pullRequest({ - number: 42, - assignees: { nodes: [{ login: "exempt" }, { login: "someone" }] }, - reviews: { nodes: [changesRequested()] }, - }), - ], - }); - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [40, 42]); - assert.equal(plan.unchanged.find((entry) => entry.number === 41)?.reason, "리뷰 게이트 면제 작성자"); -}); - -test("작성자 빚 판정은 현재 PR 번호가 없거나 잘못되면 통과시키지 않는다", () => { - assert.throws( - () => findAuthorDebts({ pullRequests: [], repository: REPO, author: "author" }), - /양의 정수/, - ); -}); - -test("입장 게이트 조회는 GraphQL search 로 어사인·작성자 두 축의 열린 PR 을 페이지 처리하고 합친다", async () => { - const calls = []; - const api = async (apiPath, options) => { - assert.equal(apiPath, "/graphql"); - assert.match(options.body.query, /search\(query: \$searchQuery/); - const { searchQuery, cursor } = options.body.variables; - calls.push([searchQuery, cursor]); - const byAssignee = searchQuery.endsWith("assignee:AuThOr"); - if (!byAssignee) { - assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open author:AuThOr"); - // 작성자 검색은 어사인 검색과 겹치는 30 을 다시 내려 준다 — 한 번만 남아야 한다. - return { - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [pullRequest({ number: 30 }), pullRequest({ number: 32 })], - }, - }, - }; - } - assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr"); - return { - data: { - search: { - pageInfo: - cursor === null - ? { hasNextPage: true, endCursor: "next" } - : { hasNextPage: false, endCursor: null }, - nodes: [pullRequest({ number: cursor === null ? 30 : 31 })], - }, - }, - }; - }; - - const pullRequests = await fetchOpenPullRequestsByAuthor(api, REPO, "AuThOr"); - - assert.deepEqual(calls, [ - ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", null], - ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", "next"], - ["repo:Afternote/Afternote-FE is:pr is:open author:AuThOr", null], - ]); - assert.deepEqual(pullRequests.map((pullRequest) => pullRequest.number), [30, 31, 32]); -}); - -test("입장 게이트 조회는 불완전한 search 결과를 빈 목록으로 접지 않는다", async () => { - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ data: { search: { pageInfo: { hasNextPage: false }, nodes: [{}] } } }), - REPO, - "author", - ), - /검색 결과가 불완전합니다/, - ); -}); - -test("두 GraphQL 조회는 결정 리뷰 권한과 하위 connection 절단 여부를 함께 요청한다", async () => { - const queries = []; - const api = async (_apiPath, options) => { - queries.push(options.body.query); - if (options.body.variables.searchQuery) { - return { - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [], - }, - }, - }; - } - return { - data: { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [], - }, - }, - }, - }; - }; - - await fetchOpenPullRequests(api, REPO); - await fetchOpenPullRequestsByAuthor(api, REPO, "author"); - - // 전체 조회 1 + 입장 조회(assignee·author) 2 - assert.equal(queries.length, 3); - for (const query of queries) { - assert.match(query, /authorCanPushToRepository/); - // 담당자 판정에 어사인이 필요하다. - assert.match(query, /assignees\(first: 10\) \{ nodes \{ login \} \}/); - for (const connection of ["reviews", "commits", "comments", "userContentEdits"]) { - assert.match( - query, - new RegExp(`${connection}\\(last: 50\\) \\{\\s+pageInfo \\{ hasPreviousPage \\}`), - ); - } - } -}); - -test("입장 게이트 조회는 잘린·누락된 하위 connection으로 판정하지 않는다", async () => { - const cases = [ - [ - "reviews", - { pageInfo: { hasPreviousPage: true }, nodes: [changesRequested()] }, - /reviews 최근 50건이 완전하지 않습니다/, - ], - ["commits", { pageInfo: {}, nodes: [] }, /commits 최근 50건이 완전하지 않습니다/], - ["comments", { pageInfo: { hasPreviousPage: false } }, /comments 응답이 불완전합니다/], - [ - "userContentEdits", - { pageInfo: { hasPreviousPage: true }, nodes: [] }, - /userContentEdits 최근 50건이 완전하지 않습니다/, - ], - ]; - - for (const [connection, value, expected] of cases) { - // 최신 판정이 변경요청이어야 판정이 이 connection 들을 실제로 읽는다. 그 상태에서만 - // 「잘린 데이터로 판정하지 않는다」 가 의미를 갖는다. - const candidate = pullRequest({ - number: 40, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [changesRequested()] }, - }); - candidate[connection] = value; - - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ), - expected, - ); - } -}); - -test("판정이 읽지 않는 connection 은 잘려 있어도 입장을 막지 않는다", async () => { - // 릴리스 PR(develop → main)은 커밋이 수백 건이라 commits(last: 50) 이 항상 잘린다. - // 그런데 리뷰 판정이 없으면 judgeAwaitingAuthor 는 커밋을 읽지도 않는다. 이 구분이 없어 - // 커밋 403건짜리 PR 하나가 그 작성자의 새 PR 을 전부 막았다 (#1787). - const releasePullRequest = pullRequest({ - number: 1778, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - commits: { pageInfo: { hasPreviousPage: true }, nodes: [] }, - }); - - const fetched = await fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [releasePullRequest], - }, - }, - }), - REPO, - "author", - ); - - assert.equal(fetched.length, 1); - assert.equal(fetched[0].number, 1778); -}); - -test("판정 시각 이후가 최근 50건 안에 다 들어오면 잘려 있어도 완전하다", async () => { - // last: 50 은 최신 50건이다. 가져온 것 중 가장 오래된 커밋이 판정 시각보다 앞서면 - // 그 이후 커밋은 전부 확보한 것이므로 판정에 공백이 없다. - const candidate = pullRequest({ - number: 41, - reviews: { - pageInfo: { hasPreviousPage: false }, - nodes: [changesRequested("2026-08-29T00:00:00Z")], - }, - commits: { - pageInfo: { hasPreviousPage: true }, - nodes: [ - { commit: { committedDate: "2026-08-28T00:00:00Z" } }, - { commit: { committedDate: "2026-08-30T00:00:00Z" } }, - ], - }, - }); - - const fetched = await fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ); - - assert.equal(fetched.length, 1); -}); - -test("판정 시각 이후가 최근 50건을 넘치면 여전히 중단한다", async () => { - // 가장 오래된 커밋조차 판정 시각 뒤라면 그 사이에 못 본 커밋이 있을 수 있다. - // 이때는 불완전한 근거로 PR 을 닫지 않도록 판정을 멈춘다. - const candidate = pullRequest({ - number: 42, - reviews: { - pageInfo: { hasPreviousPage: false }, - nodes: [changesRequested("2026-08-29T00:00:00Z")], - }, - commits: { - pageInfo: { hasPreviousPage: true }, - nodes: [ - { commit: { committedDate: "2026-08-30T00:00:00Z" } }, - { commit: { committedDate: "2026-08-31T00:00:00Z" } }, - ], - }, - }); - - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ), - /commits 최근 50건이 완전하지 않습니다/, - ); -}); - -test("최신 판정이 승인이면 대상이 아니다", () => { - // 승인 뒤 작성자가 손보는 것은 작성자 몫이지만, 그것은 «머지 가능한 상태» 이지 - // «지적을 방치한 상태» 가 아니다. - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [ - changesRequested("2026-08-01T00:00:00Z"), - { - state: "APPROVED", - submittedAt: "2026-08-02T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }, - ], - }, - userContentEdits: { - nodes: [ - { - editedAt: "2026-08-03T00:00:00Z", - editor: { login: "author" }, - }, - ], - }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, false); - assert.equal(verdict.reason, "최신 판정이 변경요청이 아님"); -}); - -test("변경요청 뒤 작성자가 PR 본문을 고치면 조치로 센다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, - userContentEdits: { - nodes: [ - { - editedAt: "2026-08-29T00:00:01Z", - editor: { login: "AUTHOR" }, - }, - ], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, false); - assert.match(verdict.reason, /본문 편집 1건/); -}); - -test("판정 전·다른 사람·편집자 없는 본문 편집은 작성자 조치가 아니다", () => { - const edits = [ - { editedAt: "2026-08-28T23:59:59Z", editor: { login: "author" } }, - { editedAt: "2026-08-29T00:00:00Z", editor: { login: "author" } }, - { editedAt: "2026-08-29T00:00:01Z", editor: { login: "reviewer" } }, - { editedAt: "2026-08-29T00:00:02Z", editor: null }, - ]; - - assert.equal( - countAuthorBodyEdits({ - userContentEdits: edits, - author: "author", - since: "2026-08-29T00:00:00Z", - }), - 0, - ); - - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, - userContentEdits: { nodes: edits }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, true); -}); - -test("리뷰어가 미는 빈 CI 재트리거 커밋은 작성자 조치가 아니다", () => { - // 8/28 에 리뷰어가 건 +0/-0 커밋 하나로 PR 3건(#1379·#1365·#882)이 전부 가짜 빚이 - // 됐다 (#1459). 여기서 같은 실수를 하면 방치된 PR 의 라벨이 조용히 떨어진다. - const withReviewerEmptyCommit = pullRequest({ - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ login: "reviewer", email: "reviewer@example.com", changed: 0 })] }, - }); - assert.equal(judgeAwaitingAuthor(withReviewerEmptyCommit, { repository: REPO }).awaiting, true); - - // 작성자가 올린 커밋이라도 바뀐 파일이 0건이면 반영이 아니다. - const ownEmptyCommit = pullRequest({ - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ changed: 0 })] }, - }); - assert.equal(judgeAwaitingAuthor(ownEmptyCommit, { repository: REPO }).awaiting, true); -}); - -test("작성자의 실질 커밋이 하나라도 있으면 대상에서 빠진다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z" })] }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, false); - assert.match(verdict.reason, /1커밋/); -}); - -test("판정 이전 커밋은 반영으로 세지 않는다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, - commits: { nodes: [commit({ date: "2026-08-28T00:00:00Z" })] }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, true); -}); - -test("병합으로 반영하고 코멘트를 남긴 PR 은 대상이 아니다", () => { - // 비병합 커밋만 세면 base 를 끌어와 충돌을 풀며 반영한 PR 이 «무조치» 가 된다. - // #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 응답도 2건 달린 채 빚에서 빠져 - // 있었다 (#1450). 커밋과 함께 작성자의 응답을 본다. - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ date: "2026-08-29T10:00:00Z", parents: 2 })] }, - comments: { nodes: [{ createdAt: "2026-08-29T11:00:00Z", author: { login: "author" } }] }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, false); - assert.match(verdict.reason, /응답 1건/); -}); - -test("작성자가 제출한 리뷰도 응답으로 센다", () => { - // 라인 코멘트를 남기면 그것을 담은 리뷰가 함께 제출된다. 별도 조회 없이 잡힌다. - const responses = countAuthorResponses({ - comments: [], - reviews: [ - changesRequested(), - { state: "COMMENTED", submittedAt: "2026-08-29T05:00:00Z", author: { login: "author" } }, - ], - author: "author", - since: "2026-08-29T00:00:00Z", - }); - assert.equal(responses, 1); -}); - -test("변경 파일 수를 알 수 없는 커밋은 반영으로 센다", () => { - // 조회 공백을 «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다. 놓치는 쪽이 - // 잘못 붙이는 쪽보다 안전하다. - const fixes = countAuthorFixes({ - commits: [commit({ date: "2026-08-29T10:00:00Z", changed: null })], - author: "author", - since: "2026-08-29T00:00:00Z", - }); - assert.equal(fixes, 1); -}); - -test("계정이 연결되지 않은 커밋은 같은 PR 의 작성자 이메일로 가린다", () => { - const fixes = countAuthorFixes({ - commits: [ - commit({ date: "2026-08-29T10:00:00Z" }), - commit({ login: null, email: "AUTHOR@example.com", date: "2026-08-29T11:00:00Z" }), - ], - author: "author", - since: "2026-08-29T00:00:00Z", - }); - assert.equal(fixes, 2); - - // 남의 이메일까지 주우면 리뷰어 커밋이 작성자 것이 된다. - const strangers = countAuthorFixes({ - commits: [commit({ login: null, email: "someone@example.com", date: "2026-08-29T11:00:00Z" })], - author: "author", - since: "2026-08-29T00:00:00Z", - }); - assert.equal(strangers, 0); -}); - -test("draft·봇·fork 는 판정 대상이 아니다", () => { - const draft = pullRequest({ isDraft: true, reviews: { nodes: [changesRequested()] } }); - assert.equal(judgeAwaitingAuthor(draft, { repository: REPO }).awaiting, false); - - const bot = pullRequest({ author: { login: "dependabot[bot]" }, reviews: { nodes: [changesRequested()] } }); - assert.equal(judgeAwaitingAuthor(bot, { repository: REPO }).awaiting, false); - - const fork = pullRequest({ - headRepository: { nameWithOwner: "someone/Afternote-FE" }, - reviews: { nodes: [changesRequested()] }, - }); - assert.equal(judgeAwaitingAuthor(fork, { repository: REPO }).awaiting, false); -}); - -test("계획은 붙일 것과 뗄 것을 한 번에 낸다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - pullRequests: [ - // 무조치 · 라벨 없음 → 붙인다 - pullRequest({ number: 10, reviews: { nodes: [changesRequested()] } }), - // 조치함 · 라벨 있음 → 뗀다 - pullRequest({ - number: 11, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ date: "2026-08-29T10:00:00Z" })] }, - }), - // 무조치 · 라벨 있음 → 그대로 - pullRequest({ - number: 12, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - }), - // 승인 · 라벨 없음 → 그대로 - pullRequest({ number: 13 }), - ], - }); - - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [10]); - assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), [11]); - assert.deepEqual(plan.unchanged.map((entry) => entry.number), [12, 13]); -}); - -test("면제 작성자의 기존 라벨을 제거하고 다음 리컨사일에서도 다시 붙이지 않는다", () => { - const pullRequests = [ - pullRequest({ - number: 40, - author: { login: "KoOnGmAi" }, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ - number: 41, - author: { login: "koongmai" }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ - number: 42, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ number: 43, reviews: { nodes: [changesRequested()] } }), - ]; - - const plan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); - - assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), [40]); - assert.equal(plan.toUnlabel[0].reason, "리뷰 게이트 면제 작성자"); - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [43]); - assert.deepEqual(plan.unchanged.map((entry) => entry.number), [41, 42]); - - // 첫 계획을 적용한 다음에도 면제 작성자의 무조치 상태는 그대로 남아 있다. - pullRequests[0].labels.nodes = []; - pullRequests[3].labels.nodes = [{ name: DEFAULT_LABEL }]; - const nextPlan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); - - assert.deepEqual(nextPlan.toLabel, []); - assert.deepEqual(nextPlan.toUnlabel, []); - assert.deepEqual( - nextPlan.unchanged.map(({ number, labeled }) => ({ number, labeled })), - [ - { number: 40, labeled: false }, - { number: 41, labeled: false }, - { number: 42, labeled: true }, - { number: 43, labeled: true }, - ], - ); -}); - -test("적용은 실패한 PR 만 보고하고 나머지를 계속 처리한다", async () => { - const api = fakeApi({ failOn: (apiPath) => apiPath.includes("/issues/10/") }); - const plan = { - toLabel: [ - { number: 10, reason: "변경요청 뒤 작성자 조치 없음" }, - { number: 11, reason: "변경요청 뒤 작성자 조치 없음" }, - ], - toUnlabel: [{ number: 12, reason: "작성자 조치 있음(1커밋 · 응답 0건)" }], - }; - - const failures = await applyPlan(api, REPO, plan, { logger: silent }); - - assert.equal(failures.length, 1); - assert.match(failures[0], /#10/); - assert.ok(api.calls.some((call) => call.apiPath === `/repos/${REPO}/issues/11/labels` && call.method === "POST")); - assert.ok( - api.calls.some( - (call) => call.apiPath === `/repos/${REPO}/issues/12/labels/${DEFAULT_LABEL}` && call.method === "DELETE", - ), - ); -}); - -test("dry-run 은 아무것도 쓰지 않는다", async () => { - const api = fakeApi(); - const plan = { toLabel: [{ number: 10, reason: "r" }], toUnlabel: [{ number: 11, reason: "r" }] }; - - await applyPlan(api, REPO, plan, { dryRun: true, logger: silent }); - - assert.equal(api.calls.filter((call) => call.method !== "GET").length, 0); -}); - -test("이미 있는 라벨은 다시 만들지 않는다", async () => { - const existing = fakeApi({ responses: { [`/repos/${REPO}/labels/${DEFAULT_LABEL}`]: { name: DEFAULT_LABEL } } }); - await ensureLabelExists(existing, REPO); - assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); - - const missing = fakeApi({ responses: { [`/repos/${REPO}/labels/${DEFAULT_LABEL}`]: () => null } }); - await ensureLabelExists(missing, REPO); - assert.ok(missing.calls.some((call) => call.apiPath === `/repos/${REPO}/labels` && call.method === "POST")); -}); - -test("요약에는 붙인 PR 의 근거가 남는다", () => { - const summary = renderSummary({ - plan: { - toLabel: [{ number: 440, author: "koongmai", decidedAt: "2026-08-29T00:00:00Z", reviewer: "Sadturtleman" }], - toUnlabel: [{ number: 11, reason: "작성자 조치 있음(1커밋 · 응답 0건)" }], - }, - dryRun: false, - }); - - assert.match(summary, /#440/); - assert.match(summary, /@koongmai/); - assert.match(summary, /2026-08-29/); - assert.match(summary, /#11 — 작성자 조치 있음/); -}); diff --git a/.github/scripts/latest-review-decision-policy.test.mjs b/.github/scripts/latest-review-decision-policy.test.mjs deleted file mode 100644 index 646187f25d..0000000000 --- a/.github/scripts/latest-review-decision-policy.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const signal = await readFile( - new URL("../workflows/latest-review-decision-event.yml", import.meta.url), - "utf8", -); -const reconcile = await readFile( - new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), - "utf8", -); -const readme = await readFile(new URL("../../README.md", import.meta.url), "utf8"); - -test("the review event workflow is an unprivileged signal only", () => { - assert.match(signal, /^\s{2}pull_request_review:\n\s{4}types: \[submitted\]/m); - assert.match(signal, /^permissions: \{\}$/m); - assert.doesNotMatch(signal, /actions\/checkout@/); - assert.doesNotMatch(signal, /GITHUB_TOKEN|GH_TOKEN|gh api|pull-requests:\s*write/); -}); - -test("the trusted workflow reconciles from the default branch with minimal write access", () => { - assert.match(reconcile, /^\s{2}workflow_run:/m); - assert.match(reconcile, /workflows: \[Latest Review Decision Event\]/); - assert.match(reconcile, /^\s{2}workflow_dispatch:/m); - assert.match(reconcile, /^permissions: \{\}$/m); - assert.match(reconcile, /^\s{6}contents: read$/m); - assert.match(reconcile, /^\s{6}pull-requests: write$/m); - assert.match(reconcile, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(reconcile, /persist-credentials: false/); - assert.match(reconcile, /reconcile-latest-review-decision\.mjs/); - assert.doesNotMatch(reconcile, /github\.event\.pull_request\.head|refs\/pull/); -}); - -test("automatic reconciliation is serialized and historical PRs can be swept manually", () => { - assert.match(reconcile, /cancel-in-progress: false/); - assert.match(reconcile, /inputs\.pull_number/); - assert.match(reconcile, /github\.event\.workflow_run\.pull_requests\[0\]\.number/); - assert.match(reconcile, /github\.event\.workflow_run\.conclusion == 'success'/); - assert.match(reconcile, /github\.event\.workflow_run\.event == 'pull_request_review'/); -}); - -test("the README documents the latest write-access review as the final decision", () => { - assert.match( - readme, - /쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다/, - ); - assert.doesNotMatch(readme, /\/review-response/); -}); diff --git a/.github/scripts/reconcile-latest-review-decision.mjs b/.github/scripts/reconcile-latest-review-decision.mjs deleted file mode 100644 index 32c076fdeb..0000000000 --- a/.github/scripts/reconcile-latest-review-decision.mjs +++ /dev/null @@ -1,308 +0,0 @@ -import { pathToFileURL } from "node:url"; - -export const DECISIVE_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED"]); - -const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]); -const NON_WRITE_PERMISSIONS = new Set(["triage", "read", "none"]); - -function requiredString(value, name) { - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`${name} 값이 없습니다.`); - } - return value.trim(); -} - -function requiredPositiveInteger(value, name) { - const number = typeof value === "number" ? value : Number(value); - if (!Number.isSafeInteger(number) || number <= 0) { - throw new Error(`${name} 값이 양의 정수가 아닙니다: ${value}`); - } - return number; -} - -function normalizeReviewState(value) { - return requiredString(value, "review.state").toUpperCase(); -} - -function normalizeRepository(repository) { - const value = requiredString(repository, "repository"); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { - throw new Error(`repository 형식이 owner/name 이 아닙니다: ${value}`); - } - return value; -} - -function normalizeDecisiveReview(review) { - const state = normalizeReviewState(review?.state); - if (!DECISIVE_REVIEW_STATES.has(state)) { - return null; - } - - const id = requiredPositiveInteger(review?.id, "review.id"); - const reviewer = requiredString( - review?.user?.login ?? review?.reviewer, - "review.user.login", - ).toLowerCase(); - const submittedAt = requiredString( - review?.submitted_at ?? review?.submittedAt, - "review.submitted_at", - ); - const submittedTimestamp = Date.parse(submittedAt); - if (!Number.isFinite(submittedTimestamp)) { - throw new Error(`review.submitted_at 값이 올바른 시각이 아닙니다: ${submittedAt}`); - } - - return { id, reviewer, state, submittedAt, submittedTimestamp }; -} - -function compareDecisiveReviews(left, right) { - return left.submittedTimestamp - right.submittedTimestamp || left.id - right.id; -} - -export function classifyRepositoryPermission(permission) { - const value = requiredString(permission, "repository permission").toLowerCase(); - if (WRITE_PERMISSIONS.has(value)) { - return "write"; - } - if (NON_WRITE_PERMISSIONS.has(value)) { - return "read"; - } - throw new Error(`알 수 없는 repository permission 입니다: ${permission}`); -} - -export function selectLatestDecisiveReviewsByReviewer(reviews) { - if (!Array.isArray(reviews)) { - throw new Error("리뷰 목록 응답이 배열이 아닙니다."); - } - - const latestByReviewer = new Map(); - for (const rawReview of reviews) { - const review = normalizeDecisiveReview(rawReview); - if (!review) { - continue; - } - const previous = latestByReviewer.get(review.reviewer); - if (!previous || compareDecisiveReviews(previous, review) <= 0) { - latestByReviewer.set(review.reviewer, review); - } - } - - return [...latestByReviewer.values()].sort(compareDecisiveReviews); -} - -export function buildDismissalPlan(reviews, permissionByReviewer) { - if (!(permissionByReviewer instanceof Map)) { - throw new Error("permissionByReviewer 는 Map 이어야 합니다."); - } - - const latestByReviewer = selectLatestDecisiveReviewsByReviewer(reviews); - const writeReviews = latestByReviewer.filter((review) => { - if (!permissionByReviewer.has(review.reviewer)) { - throw new Error(`@${review.reviewer}의 repository permission 이 없습니다.`); - } - return classifyRepositoryPermission(permissionByReviewer.get(review.reviewer)) === "write"; - }); - - if (writeReviews.length === 0) { - return { status: "no-write-review", latestReview: null, blockingReviews: [] }; - } - - const latestReview = writeReviews.at(-1); - if (latestReview.state !== "APPROVED") { - return { status: "latest-changes-requested", latestReview, blockingReviews: [] }; - } - - const blockingReviews = writeReviews.filter((review) => review.state === "CHANGES_REQUESTED"); - if (blockingReviews.length === 0) { - return { status: "already-approved", latestReview, blockingReviews: [] }; - } - - return { status: "dismiss", latestReview, blockingReviews }; -} - -export class GitHubClient { - constructor({ token, apiUrl = "https://api.github.com", fetchImpl = globalThis.fetch }) { - this.token = requiredString(token, "GITHUB_TOKEN"); - this.apiUrl = requiredString(apiUrl, "GITHUB_API_URL").replace(/\/+$/, ""); - if (typeof fetchImpl !== "function") { - throw new Error("fetch 구현이 없습니다."); - } - this.fetchImpl = fetchImpl; - } - - async request(path, { method = "GET", body } = {}) { - if (typeof path !== "string" || !path.startsWith("/")) { - throw new Error(`GitHub API path가 올바르지 않습니다: ${path}`); - } - - const response = await this.fetchImpl(`${this.apiUrl}${path}`, { - method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const responseText = await response.text(); - let data = null; - if (responseText !== "") { - try { - data = JSON.parse(responseText); - } catch { - throw new Error(`GitHub API가 JSON이 아닌 응답을 반환했습니다: ${response.status}`); - } - } - if (!response.ok) { - const detail = typeof data?.message === "string" ? data.message : responseText.slice(0, 500); - throw new Error(`GitHub API ${method} ${path} 실패: HTTP ${response.status} ${detail}`); - } - return data; - } - - async paginate(path) { - const items = []; - for (let page = 1; ; page += 1) { - const separator = path.includes("?") ? "&" : "?"; - const response = await this.request(`${path}${separator}per_page=100&page=${page}`); - if (!Array.isArray(response)) { - throw new Error(`GitHub API pagination 응답이 배열이 아닙니다: ${path}`); - } - items.push(...response); - if (response.length < 100) { - return items; - } - } - } -} - -export async function planPullRequest(client, repository, pullNumber) { - const normalizedRepository = normalizeRepository(repository); - const normalizedPullNumber = requiredPositiveInteger(pullNumber, "pull number"); - const reviews = await client.paginate( - `/repos/${normalizedRepository}/pulls/${normalizedPullNumber}/reviews`, - ); - const latestByReviewer = selectLatestDecisiveReviewsByReviewer(reviews); - const permissionByReviewer = new Map(); - - for (const review of latestByReviewer) { - const response = await client.request( - `/repos/${normalizedRepository}/collaborators/${encodeURIComponent(review.reviewer)}/permission`, - ); - permissionByReviewer.set(review.reviewer, response?.permission); - } - - return { - pullNumber: normalizedPullNumber, - ...buildDismissalPlan(reviews, permissionByReviewer), - }; -} - -export async function applyDismissalPlan(client, repository, plan) { - const normalizedRepository = normalizeRepository(repository); - if (plan?.status !== "dismiss") { - return []; - } - - const pullNumber = requiredPositiveInteger(plan.pullNumber, "pull number"); - const approval = normalizeDecisiveReview(plan.latestReview); - if (!approval || approval.state !== "APPROVED") { - throw new Error(`#${pullNumber} dismissal plan의 최종 승인이 올바르지 않습니다.`); - } - - const dismissed = []; - for (const rawReview of plan.blockingReviews) { - const blockingReview = normalizeDecisiveReview(rawReview); - if (!blockingReview || blockingReview.state !== "CHANGES_REQUESTED") { - throw new Error(`#${pullNumber} dismissal plan에 변경요청이 아닌 리뷰가 있습니다.`); - } - if (compareDecisiveReviews(blockingReview, approval) >= 0) { - throw new Error(`#${pullNumber} 최종 승인보다 늦은 변경요청은 dismiss할 수 없습니다.`); - } - - const message = [ - `@${approval.reviewer}의 더 늦은 승인(review #${approval.id})을 최종 판정으로 사용합니다.`, - "저장소 정책은 PR 전체에서 가장 최근의 APPROVED/CHANGES_REQUESTED 판정을 우선합니다.", - ].join(" "); - const response = await client.request( - `/repos/${normalizedRepository}/pulls/${pullNumber}/reviews/${blockingReview.id}/dismissals`, - { method: "PUT", body: { event: "DISMISS", message } }, - ); - if (normalizeReviewState(response?.state) !== "DISMISSED") { - throw new Error(`#${pullNumber} review #${blockingReview.id} dismiss 응답이 DISMISSED가 아닙니다.`); - } - dismissed.push(blockingReview); - } - return dismissed; -} - -export async function reconcileLatestReviewDecisions({ client, repository, pullNumbers, logger = console }) { - if (!Array.isArray(pullNumbers)) { - throw new Error("pullNumbers 는 배열이어야 합니다."); - } - const normalizedPullNumbers = [...new Set( - pullNumbers.map((pullNumber) => requiredPositiveInteger(pullNumber, "pull number")), - )]; - - // 모든 PR의 조회와 권한 판정을 먼저 끝낸다. 한 PR이라도 근거 조회가 실패하면 - // 다른 PR의 blocking review까지 일부만 dismiss하지 않는다. - const plans = []; - for (const pullNumber of normalizedPullNumbers) { - plans.push(await planPullRequest(client, repository, pullNumber)); - } - - const results = []; - for (const plan of plans) { - const dismissed = await applyDismissalPlan(client, repository, plan); - if (dismissed.length === 0) { - logger.log(`#${plan.pullNumber}: ${plan.status}`); - } else { - logger.log( - `#${plan.pullNumber}: @${plan.latestReview.reviewer} 승인 뒤 변경요청 ${dismissed.length}건 dismiss`, - ); - } - results.push({ plan, dismissed }); - } - return results; -} - -export function parseTargetPullNumber(value) { - if (value === undefined || value === null || String(value).trim() === "") { - return null; - } - return requiredPositiveInteger(String(value).trim(), "TARGET_PULL_NUMBER"); -} - -async function main() { - const repository = normalizeRepository(process.env.GITHUB_REPOSITORY); - const client = new GitHubClient({ - token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, - apiUrl: process.env.GITHUB_API_URL ?? "https://api.github.com", - }); - const targetPullNumber = parseTargetPullNumber(process.env.TARGET_PULL_NUMBER); - let pullNumbers; - - if (targetPullNumber === null) { - const openPullRequests = await client.paginate(`/repos/${repository}/pulls?state=open`); - pullNumbers = openPullRequests.map((pullRequest) => - requiredPositiveInteger(pullRequest?.number, "pull_request.number")); - } else { - pullNumbers = [targetPullNumber]; - } - - console.log(`Latest review decision reconcile: ${pullNumbers.length} open pull request(s)`); - await reconcileLatestReviewDecisions({ client, repository, pullNumbers }); -} - -const isDirectExecution = process.argv[1] - && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isDirectExecution) { - try { - await main(); - } catch (error) { - console.error(`::error::${error.message}`); - process.exitCode = 1; - } -} diff --git a/.github/scripts/reconcile-latest-review-decision.test.mjs b/.github/scripts/reconcile-latest-review-decision.test.mjs deleted file mode 100644 index acc6303bc6..0000000000 --- a/.github/scripts/reconcile-latest-review-decision.test.mjs +++ /dev/null @@ -1,275 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - applyDismissalPlan, - buildDismissalPlan, - classifyRepositoryPermission, - parseTargetPullNumber, - reconcileLatestReviewDecisions, - selectLatestDecisiveReviewsByReviewer, -} from "./reconcile-latest-review-decision.mjs"; - -function review({ - id, - reviewer, - state, - submittedAt, -}) { - return { - id, - state, - submitted_at: submittedAt, - user: { login: reviewer }, - }; -} - -const changesByAlice = review({ - id: 10, - reviewer: "Alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:00:00Z", -}); -const approvalByBob = review({ - id: 20, - reviewer: "Bob", - state: "APPROVED", - submittedAt: "2026-08-26T02:00:00Z", -}); - -test("latest decisive review is selected separately for each reviewer", () => { - const reviews = [ - changesByAlice, - review({ - id: 11, - reviewer: "alice", - state: "COMMENTED", - submittedAt: "2026-08-26T02:30:00Z", - }), - review({ - id: 12, - reviewer: "ALICE", - state: "APPROVED", - submittedAt: "2026-08-26T03:00:00Z", - }), - approvalByBob, - ]; - - assert.deepEqual( - selectLatestDecisiveReviewsByReviewer(reviews).map(({ id, reviewer, state }) => ({ - id, - reviewer, - state, - })), - [ - { id: 20, reviewer: "bob", state: "APPROVED" }, - { id: 12, reviewer: "alice", state: "APPROVED" }, - ], - ); -}); - -test("a later write-access approval dismisses another reviewer's blocking request", () => { - const plan = buildDismissalPlan( - [changesByAlice, approvalByBob], - new Map([["alice", "write"], ["bob", "maintain"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.equal(plan.latestReview.id, 20); - assert.deepEqual(plan.blockingReviews.map(({ id }) => id), [10]); -}); - -test("one later approval dismisses every older write-access blocking request", async () => { - const changesByCarol = review({ - id: 15, - reviewer: "Carol", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:30:00Z", - }); - const plan = { - pullNumber: 914, - ...buildDismissalPlan( - [changesByAlice, changesByCarol, approvalByBob], - new Map([["alice", "write"], ["bob", "maintain"], ["carol", "admin"]]), - ), - }; - const calls = []; - const client = { - async request(path) { - calls.push(path); - return { state: "DISMISSED" }; - }, - }; - - const dismissed = await applyDismissalPlan(client, "Afternote/Afternote-FE", plan); - - assert.deepEqual(dismissed.map(({ id }) => id), [10, 15]); - assert.deepEqual(calls, [ - "/repos/Afternote/Afternote-FE/pulls/914/reviews/10/dismissals", - "/repos/Afternote/Afternote-FE/pulls/914/reviews/15/dismissals", - ]); -}); - -test("a later change request still blocks after an earlier approval", () => { - const plan = buildDismissalPlan( - [approvalByBob, review({ - id: 30, - reviewer: "Alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T03:00:00Z", - })], - new Map([["alice", "admin"], ["bob", "write"]]), - ); - - assert.equal(plan.status, "latest-changes-requested"); - assert.deepEqual(plan.blockingReviews, []); -}); - -test("a reviewer's own later approval supersedes their earlier change request", () => { - const plan = buildDismissalPlan( - [ - changesByAlice, - review({ - id: 15, - reviewer: "Alice", - state: "APPROVED", - submittedAt: "2026-08-26T01:30:00Z", - }), - review({ - id: 12, - reviewer: "Carol", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:15:00Z", - }), - ], - new Map([["alice", "write"], ["carol", "write"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.deepEqual(plan.blockingReviews.map(({ id }) => id), [12]); -}); - -test("read-only approval never overrides a write-access change request", () => { - const plan = buildDismissalPlan( - [changesByAlice, approvalByBob], - new Map([["alice", "write"], ["bob", "read"]]), - ); - - assert.equal(plan.status, "latest-changes-requested"); - assert.equal(plan.latestReview.id, 10); -}); - -test("same-second reviews use the larger review id as the later decision", () => { - const submittedAt = "2026-08-26T01:00:00Z"; - const plan = buildDismissalPlan( - [ - review({ id: 100, reviewer: "Alice", state: "CHANGES_REQUESTED", submittedAt }), - review({ id: 101, reviewer: "Bob", state: "APPROVED", submittedAt }), - ], - new Map([["alice", "write"], ["bob", "write"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.equal(plan.latestReview.id, 101); -}); - -test("unknown or missing permissions fail closed", () => { - assert.equal(classifyRepositoryPermission("admin"), "write"); - assert.equal(classifyRepositoryPermission("triage"), "read"); - assert.throws(() => classifyRepositoryPermission("mystery"), /알 수 없는/); - assert.throws( - () => buildDismissalPlan([approvalByBob], new Map()), - /repository permission 이 없습니다/, - ); -}); - -test("only valid optional target pull numbers are accepted", () => { - assert.equal(parseTargetPullNumber(undefined), null); - assert.equal(parseTargetPullNumber(""), null); - assert.equal(parseTargetPullNumber(" 914 "), 914); - assert.throws(() => parseTargetPullNumber("0"), /양의 정수/); - assert.throws(() => parseTargetPullNumber("abc"), /양의 정수/); -}); - -test("dismissal calls the protected review endpoint and verifies the response", async () => { - const calls = []; - const client = { - async request(path, options) { - calls.push({ path, options }); - return { state: "DISMISSED" }; - }, - }; - const plan = { - pullNumber: 914, - status: "dismiss", - latestReview: { - id: 20, - reviewer: "bob", - state: "APPROVED", - submittedAt: "2026-08-26T02:00:00Z", - submittedTimestamp: Date.parse("2026-08-26T02:00:00Z"), - }, - blockingReviews: [{ - id: 10, - reviewer: "alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:00:00Z", - submittedTimestamp: Date.parse("2026-08-26T01:00:00Z"), - }], - }; - - const dismissed = await applyDismissalPlan(client, "Afternote/Afternote-FE", plan); - - assert.deepEqual(dismissed.map(({ id }) => id), [10]); - assert.equal( - calls[0].path, - "/repos/Afternote/Afternote-FE/pulls/914/reviews/10/dismissals", - ); - assert.equal(calls[0].options.method, "PUT"); - assert.equal(calls[0].options.body.event, "DISMISS"); - assert.match(calls[0].options.body.message, /@bob/); - assert.match(calls[0].options.body.message, /review #20/); -}); - -test("an unexpected dismiss response fails instead of reporting success", async () => { - const client = { async request() { return { state: "CHANGES_REQUESTED" }; } }; - await assert.rejects( - applyDismissalPlan(client, "Afternote/Afternote-FE", { - pullNumber: 914, - status: "dismiss", - latestReview: approvalByBob, - blockingReviews: [changesByAlice], - }), - /DISMISSED가 아닙니다/, - ); -}); - -test("all pull requests are read and authorized before the first dismissal", async () => { - const writes = []; - const client = { - async paginate(path) { - if (path.includes("/pulls/1/reviews")) { - return [changesByAlice, approvalByBob]; - } - throw new Error("second PR review lookup failed"); - }, - async request(path, options = {}) { - if (options.method === "PUT") { - writes.push(path); - return { state: "DISMISSED" }; - } - return { permission: "write" }; - }, - }; - - await assert.rejects( - reconcileLatestReviewDecisions({ - client, - repository: "Afternote/Afternote-FE", - pullNumbers: [1, 2], - logger: { log() {} }, - }), - /second PR review lookup failed/, - ); - assert.deepEqual(writes, []); -}); diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs deleted file mode 100644 index 1c507ddb5c..0000000000 --- a/.github/scripts/review-debt-policy.test.mjs +++ /dev/null @@ -1,249 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); -const requestAll = await readFile(new URL("../workflows/review-request-all.yml", import.meta.url), "utf8"); - -test("team membership is judged by real permission, not author_association", () => { - // 웹훅 페이로드의 author_association 은 조직 멤버십이 비공개면 MEMBER 대신 - // CONTRIBUTOR 를 내린다. 이 조직은 공개 멤버가 0명이라, 레포에 직접 초대된 한 - // 계정만 COLLABORATOR 로 찍혀 검사를 받고 나머지 팀원은 전원 건너뛰어졌다. - // 가드가 한 사람만 막는 상태였다 (8/25 #1152·#1154 통과 / #1112 만 닫힘). - // 주석은 이력이라 남기고, 실제로 값을 끌어오는 자리만 금지한다. - assert.doesNotMatch(guard, /^\s*[A-Z_]+:\s*\$\{\{[^\n]*author_association/m); - assert.doesNotMatch(guard, /\$ASSOCIATION/); - assert.match(guard, /collaborators\/\$AUTHOR\/permission/); -}); - -test("only write-capable accounts are subject to the guard", () => { - // read 권한만 있는 계정은 리뷰로 판정을 낼 수 없으니 빚을 질 수도 없다. - assert.match(guard, /^\s*admin\|maintain\|write\)/m); - // 빈 값·예상 밖 값까지 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. - assert.match(guard, /^\s*read\|none\)[^\n]*exit 0/m); - assert.match(guard, /^\s*\*\) echo "::error::권한 값을 알 수 없다[^\n]*exit 1/m); -}); - -test("a failed permission lookup fails the run instead of passing the pull request", () => { - // 조회 실패를 «권한 없음» 으로 접으면 가드 전체가 조용히 무력해진다. - assert.match(guard, /if ! perm=\$\(gh api[^\n]*\n\s*echo "::error::[^\n]*\n\s*exit 1/); -}); - -test("forks are excluded by head repository, not by contributor status", () => { - // fork PR 은 토큰이 read-only 라 닫지도 못한다. 그것이 원래 걸러내려던 것이고, - // author_association 은 그 판정에 쓸 수 없는 축이었다. - assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); -}); - -test("the debt sweep still fails closed when open pull requests cannot be listed", () => { - // 조회 실패를 «빚 없음» 으로 오인하면 새 PR 이 전부 통과한다. - assert.match(guard, /if ! open_prs=\$\(gh api[^\n]*\n(?:[^\n]*\n)*?\s*exit 1/); -}); - -test("a pull request is never counted as debt against its own responsible person", () => { - // «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 있으면 작성자) 기준이다(#1974). - // 작성자 로그인만 보면 남이 올려 내게 어사인된 PR 을 내가 리뷰해야 하는 것으로 세고, - // 내가 올려 남에게 어사인된 PR 을 내 리뷰 목록에서 뺀다. - assert.doesNotMatch(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); - assert.match(guard, /if \(\.assignees \| length\) > 0 then \[\.assignees\[\]\.login\] else \[\.user\.login\] end/); - assert.match(guard, /select\(\(\\\$owners \| index\(\\"\$author_lc\\"\)\) == null\)/); -}); - -test("the request-all job excludes the responsible person, not only the author", () => { - // GitHub 은 작성자만 리뷰어에서 자동으로 뺀다. 어사인된 담당자는 직접 빼야 한다. - assert.match(requestAll, /ASSIGNEES: \$\{\{ join\(github\.event\.pull_request\.assignees\.\*\.login, ' '\) \}\}/); - assert.match(requestAll, /owners_lc=\$\(printf '%s' "\$\{ASSIGNEES:-\$AUTHOR\}"/); - assert.match(requestAll, /case " \$owners_lc " in\n\s*\*" \$u_lc "\*\) continue ;;/); - assert.doesNotMatch(requestAll, /\[ "\$u" != "\$AUTHOR" \] \|\| continue/); -}); - -test("the dead review_request_removed path is gone", () => { - // 빚 기준은 여전히 열린 PR 전체다. 변경요청 뒤 명시적 재요청 여부를 확인하더라도 - // 요청 제거 자체를 별도 감사하는 job 은 가드 런 19건 동안 발동한 적이 없었다. - // 주석은 이력이라 남기고, 실제로 트리거를 걸고 job 을 세우는 자리만 금지한다. - assert.doesNotMatch(guard, /^\s*types:[^\n]*review_request_removed/m); - assert.doesNotMatch(guard, /^\s{2}bypass-audit:/m); -}); - -test("changes requested becomes debt only after explicit rerequest and a fix", () => { - // 수정 커밋만으로 리뷰 의무를 만들지 않는다. 변경요청을 낸 리뷰어가 현재 요청 - // 목록에 다시 들어온 경우에만 반영 여부를 보고 빚으로 센다. - assert.match(guard, /CHANGES_REQUESTED/); - assert.match(guard, /requested_reviewers\[\]\.login/); - assert.match(guard, /blocked_by/); - assert.match(guard, /명시적 재리뷰 요청 없음/); - assert.match(guard, /sort_by\(\.t\) \| last/); - assert.doesNotMatch(guard, /group_by\(\.u/); - assert.match(guard, /\(\.parents \| length\) < 2/); - // 반영 판정의 기준 시각은 여전히 최신 변경요청 시각이다. 집계가 jq 에서 awk 로 - // 옮겨 갔을 뿐, 그 이전 커밋을 반영으로 세지 않는다. - assert.match(guard, /-v cutoff="\$blocked_at"/); - assert.match(guard, /\$4 > cutoff/); -}); - -test("a reviewer's own commit is never counted as the author's fix", () => { - // 누가 올렸는지를 안 보면 리뷰어가 미는 CI 재트리거 커밋이 «작성자가 반영했다» 가 - // 된다. 8/28 에 리뷰어가 건 +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 - // 전부 가짜 빚이 됐고, 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열었다 (#1459). - assert.match(guard, /\(\.author\.login \/\/ ""\)/); - // 담당자는 여럿일 수 있어 집합으로 가린다. - assert.match(guard, /login in target/); - assert.match(guard, /-v owners="\$pr_owners"/); - // 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 담당자 - // 것으로 확인된 커밋의 이메일을 폴백 신원으로 쓴다. - assert.match(guard, /\(\.commit\.author\.email \/\/ ""\)/); - assert.match(guard, /email in own/); -}); - -test("an empty commit is not a fix", () => { - // pulls/{n}/commits 응답에는 파일 정보가 없다. 작성자 커밋으로 좁힌 후보에 한해 - // commits/{sha} 를 조회해 바뀐 파일이 0건이면 버린다. - assert.match(guard, /repos\/\$REPO\/commits\/\$sha/); - assert.match(guard, /\(\.files \/\/ \[\]\) \| length/); - assert.match(guard, /\[ "\$changed" -gt 0 \] \|\| continue/); - // 조회 실패나 깨진 응답을 반영·미반영 어느 쪽으로도 접지 않는다. 근거가 완전하지 - // 않으면 가드를 실패시켜 사람의 정상 PR 을 닫는 오탐을 막는다. - assert.doesNotMatch(guard, /changed=1/); - assert.match(guard, /변경 파일 조회에 실패했다/); - assert.match(guard, /변경 파일 수가 올바르지 않다/); -}); - -test("review evidence API failures stop the guard before it can close a pull request", () => { - // 리뷰·커밋·댓글을 못 읽은 상태는 «응답 없음» 이 아니다. 각 근거 조회가 실패하면 - // 즉시 종료하고, `|| true` 나 stderr 폐기로 빈 결과를 만들어서는 안 된다. - for (const message of [ - "리뷰 조회에 실패했다", - "커밋 조회에 실패했다", - "변경 파일 조회에 실패했다", - "작성자 일반 응답 조회에 실패했다", - "작성자 리뷰 응답 조회에 실패했다", - "본문 편집 이력 조회에 실패했다", - "본문 편집 이력이 불완전하다", - ]) { - assert.match(guard, new RegExp(message)); - } - assert.doesNotMatch(guard, /gh api[^\n]*(?:\n[^\n]*){0,4}\|\| true/); - assert.doesNotMatch(guard, /gh api[^\n]*2>\/dev\/null/); -}); - -test("a fix delivered by a merge commit still counts as a response", () => { - // 비병합 커밋만 세면 base 를 끌어와 충돌을 풀며 반영한 PR 이 «미반영» 이 된다. - // #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 응답 코멘트도 2건 달린 - // 채로 빚에서 빠져 있었다. 커밋과 함께 작성자의 응답을 본다 (#1450). - assert.match(guard, /issues\/\$pn\/comments/); - assert.match(guard, /pulls\/\$pn\/comments/); - // 담당자·시각 판정은 review-debt-query.test.mjs에서 실제 jq로 검증한다. - assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); -}); - -test("counts are taken per item so paginated pull requests do not break the comparison", () => { - // --paginate 는 페이지마다 jq 를 돌린다. 페이지 단위로 length 를 뽑으면 커밋이 - // 100건을 넘는 PR 에서 숫자가 여러 줄로 나와 -gt 비교가 죽는다. - assert.doesNotMatch(guard, /\| length" 2>\/dev\/null/); - assert.match(guard, /\| wc -l \| tr -d ' '/); -}); - -test("rerequests are automated so silence cannot pass the guard", () => { - // 가드는 변경요청을 낸 리뷰어에게 요청이 다시 걸린 경우에만 빚으로 센다. 그 - // 되살리기를 작성자 손에 맡기면 아무도 걸지 않아 가드가 통째로 무력해진다 — - // 8/29 실측에서 반영까지 끝난 7건이 전원 «빚 아님» 이었다 (#1450). - assert.match(requestAll, /^\s*types: \[opened, ready_for_review, reopened, synchronize, edited\]/m); - assert.match(requestAll, /^\s{2}rerequest:/m); - assert.match(requestAll, /github\.event\.action == 'synchronize'/); - assert.match(requestAll, /github\.event\.action == 'edited'/); - assert.match(requestAll, /github\.event\.changes\.body != null/); - // 본문 편집은 담당자(어사인, 비어 있으면 작성자)의 것만 조치다(#1974). - assert.match( - requestAll, - /contains\(github\.event\.pull_request\.assignees\.\*\.login, github\.event\.sender\.login\)/, - ); - assert.match( - requestAll, - /github\.event\.pull_request\.assignees\[0\] == null &&\n\s*github\.event\.sender\.login == github\.event\.pull_request\.user\.login/, - ); - // 담당자가 낸 변경요청은 되살리지 않는다. - assert.match(requestAll, /\*" \$blocked_lc "\*\) echo "변경요청을 낸 사람이 담당자 본인/); - assert.match(requestAll, /github\.event\.pull_request\.state == 'open'/); - assert.match(requestAll, /--add-reviewer "\$blocked"/); - // 기존 전원 요청은 반영 커밋이나 본문 편집마다 다시 돌지 않는다. - assert.match(requestAll, /github\.event\.action != 'synchronize'/); - assert.match(requestAll, /github\.event\.action != 'edited'/); -}); - -test("rerequest review lookup fails closed because body edits are one-shot events", () => { - assert.match(requestAll, /리뷰 조회에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것/); - assert.match(requestAll, /최신 리뷰 판정에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것/); - assert.doesNotMatch(requestAll, /gh api[^\n]*(?:\n[^\n]*){0,4}2>\/dev\/null/); - assert.doesNotMatch(requestAll, /gh api[^\n]*(?:\n[^\n]*){0,4}\|\| true/); -}); - -test("an edited event cannot be attributed to a later change request", () => { - assert.match(requestAll, /EVENT_AT: \$\{\{ github\.event\.pull_request\.updated_at \}\}/); - assert.match(requestAll, /\$action != "edited" or \.t < \$event_at/); - assert.match(requestAll, /본문 편집 이벤트 시각이 없다/); -}); - -test("author body edits are durable review evidence and fail closed when truncated", () => { - // updated_at 은 댓글·라벨까지 섞이고 lastEditedAt 은 마지막 편집자만 남긴다. 영속 - // userContentEdits 에서 최신 변경요청 뒤 PR 작성자의 편집만 세어야 한다. - assert.match(guard, /userContentEdits\(last:50\)/); - assert.match(guard, /editor\{login\}/); - assert.match(guard, /\.editor\.login/); - assert.match(guard, /\.editedAt > \$cutoff/); - assert.match(guard, /--arg owners ",\$pr_owners,"/); - assert.match(guard, /pageInfo\.hasPreviousPage != false/); - assert.match(guard, /\[ "\$body_edits" -gt 0 \]/); -}); - -test("the rerequest job and the guard judge by the same latest decision", () => { - // 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 이어지지 않거나, - // 이미 승인된 PR 에 요청을 되살린다. - for (const wf of [guard, requestAll]) { - assert.match(wf, /sort_by\(\.t\) \| last/); - assert.match(wf, /CHANGES_REQUESTED/); - } - // 봇·fork 는 토큰이 read-only 라 요청을 걸 수 없다. - assert.match(requestAll, /\[ "\$HEAD_REPO" != "\$REPO" \]/); -}); - -function spaceSeparatedEnv(workflow, name) { - const match = new RegExp(`^\\s*${name}: ([^\\n#]+)$`, "m").exec(workflow); - assert.ok(match, `${name} 환경변수를 찾지 못했다`); - return match[1].trim().split(/\s+/).filter(Boolean).map((login) => login.toLowerCase()); -} - -test("the gate exemption is judged before any debt is counted", () => { - // 면제를 빚 계산 뒤에 두면, 면제받은 사람의 PR 때문에 조회가 실패했을 때 가드가 그를 - // 대신해 죽는다. 봇·fork·권한 판정과 같은 자리, 열린 PR 을 훑기 전에 둔다 (#1910). - const exemptAt = guard.indexOf("리뷰 게이트 면제 작성자"); - const sweepAt = guard.indexOf("if ! open_prs=$(gh api"); - assert.ok(exemptAt > 0, "면제 경로가 없다"); - assert.ok(sweepAt > exemptAt, "면제가 빚 계산 뒤에 있다"); - assert.match(guard, /리뷰 게이트 면제 작성자\(\$AUTHOR\)[^\n]*exit 0/); -}); - -test("exempt logins are matched case-insensitively", () => { - // GitHub 로그인은 대소문자를 가리지 않는다. 그대로 비교하면 웹훅이 Koongmai 를 내리는 - // 순간 면제가 조용히 풀리고, 그 사람의 PR 이 닫힌다. - assert.match(guard, /author_lc=\$\(printf '%s' "\$AUTHOR" \| tr '\[:upper:\]' '\[:lower:\]'\)/); - assert.match(guard, /exempt_lc=\$\(printf '%s' "\$REVIEW_GATE_EXEMPT_AUTHORS" \| tr '\[:upper:\]' '\[:lower:\]'\)/); -}); - -test("a change request from an exempt reviewer is not charged to the rest of the team", () => { - // 면제된 사람은 리뷰할 의무가 없다. 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 - // 대가를 나머지 팀원이 "새 PR 을 못 연다" 로 치른다. 그래도 그 PR 이 그냥 머지되지는 - // 않는다. 승인 1건은 required-approval 룰셋이 계속 요구한다. - assert.match(guard, /case " \$exempt_lc " in\n\s*\*" \$blocked_by "\*\)/); - assert.match(guard, /면제 리뷰어 @\$blocked_by/); -}); - -test("nobody is exempt from the gate while still on the automatic review roster", () => { - // 리뷰 의무가 없는 사람에게 요청만 계속 걸면, 그 요청은 아무도 응답하지 않는 알림으로 - // 쌓이고 두 워크플로가 서로 다른 팀 명단을 갖게 된다. - const exempt = spaceSeparatedEnv(guard, "REVIEW_GATE_EXEMPT_AUTHORS"); - const team = spaceSeparatedEnv(requestAll, "TEAM"); - for (const login of exempt) { - assert.ok(!team.includes(login), `${login} 이 면제이면서 자동 요청 대상이다`); - } - assert.ok(team.length > 0, "자동 요청 대상이 비었다"); -}); diff --git a/.github/scripts/review-debt-query.test.mjs b/.github/scripts/review-debt-query.test.mjs deleted file mode 100644 index 9751d19ab5..0000000000 --- a/.github/scripts/review-debt-query.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; - -const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); -const cutoff = "2026-09-10T00:00:00Z"; -const owners = "koongmai,1hyok"; - -function runQuery(query, data, args = []) { - return spawnSync("jq", ["-r", ...args, query], { input: JSON.stringify(data), encoding: "utf8" }); -} - -for (const endpoint of ["issues", "pulls"]) { - test(`${endpoint} comments retain author context and count only later owner responses`, () => { - const start = guard.indexOf(`repos/$REPO/${endpoint}/$pn/comments?per_page=100`); - assert.ok(start >= 0); - const raw = guard.slice(start).match(/--jq "([\s\S]*?)" \\\n/)[1]; - const query = raw.replaceAll("$pr_owners", owners).replaceAll("$blocked_at", cutoff) - .replaceAll('\\"', '"').replaceAll('\\$', '$'); - const comments = [ - { id: 1, user: { login: "KoongMai" }, created_at: "2026-09-11T00:00:00Z" }, - { id: 2, user: { login: "1hyok" }, created_at: "2026-09-12T00:00:00Z" }, - { id: 3, user: { login: "koongmai" }, created_at: cutoff }, - { id: 4, user: { login: "koong" }, created_at: "2026-09-11T00:00:00Z" }, - { id: 5, user: { login: "other" }, created_at: "2026-09-11T00:00:00Z" }, - ]; - // gh --paginate executes its query separately on each page. - const pages = [comments.slice(0, 1), comments.slice(1), []]; - const output = pages.map(page => { - const result = runQuery(query, page); - assert.equal(result.status, 0, result.stderr); - return result.stdout; - }).join(""); - assert.equal(output, "1\n2\n"); - }); -} - -const bodyQuery = guard.match(/--arg cutoff "\$blocked_at" '([\s\S]*?)' <<< "\$edits_json"/)[1]; -function edits(nodes, hasPreviousPage = false) { - return { data: { repository: { pullRequest: { userContentEdits: { nodes, pageInfo: { hasPreviousPage } } } } } }; -} -const args = ["--arg", "owners", `,${owners},`, "--arg", "cutoff", cutoff]; - -test("body edit evidence keeps editor context, cutoff, and exact owner membership", () => { - const result = runQuery(bodyQuery, edits([ - { editor: { login: "KOONGMAI" }, editedAt: "2026-09-11T00:00:00Z" }, - { editor: { login: "1hyok" }, editedAt: "2026-09-12T00:00:00Z" }, - { editor: { login: "koongmai" }, editedAt: cutoff }, - { editor: { login: "koong" }, editedAt: "2026-09-11T00:00:00Z" }, - { editor: null, editedAt: "2026-09-11T00:00:00Z" }, - ]), args); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "2\n"); - assert.equal(runQuery(bodyQuery, edits([]), args).stdout, "0\n"); -}); - -test("truncated body edit history still fails closed", () => { - const result = runQuery(bodyQuery, edits([], true), args); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /최근 50건보다 많은 본문 편집 이력/); -}); diff --git a/.github/scripts/validate-pr-issue-link.mjs b/.github/scripts/validate-pr-issue-link.mjs index abd00791e4..3319f1c681 100644 --- a/.github/scripts/validate-pr-issue-link.mjs +++ b/.github/scripts/validate-pr-issue-link.mjs @@ -75,7 +75,6 @@ function issueAssigneeLogins(issue) { } // 봇은 Issue 담당자로 지정할 수 없으므로 담당자 대조에서 면제한다. -// review-debt-guard·review-request-all 의 봇 면제 규약과 같은 경계다. function isBotAuthor(user, login) { return user?.type === "Bot" || login.endsWith("[bot]"); } diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 5b0cb8cea3..b99565c38b 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -8,10 +8,6 @@ name: PR Label Reconcile # 같은 자리에서 이슈 쪽 라벨도 맞춘다 (#1532) — base 가 기본 브랜치가 아닌 PR 은 이슈의 # Development 칸에 뜨지 않아, 이슈만 보면 아무도 손대지 않은 것으로 읽힌다. # -# 변경요청 뒤 작성자가 아무 조치도 하지 않은 PR 에도 라벨을 붙인다 (#1552). `review-debt-guard` -# 는 리뷰어의 빚만 잡고 그 상태를 «공은 작성자에게» 로 흘려보내므로, 그쪽 축은 어디서도 신호가 -# 나지 않았다. 붙이기와 떼기를 한 번의 차이 계산으로 처리해 스테일 라벨이 남지 않게 한다. -# # 열린 PR 끼리의 «형제 충돌» 도 같은 리컨사일러가 잡는다 (#1750). `conflict` 라벨은 base 와의 충돌만 # 보므로, 두 PR 이 같은 파일을 서로 다르게 고친 사실은 한쪽이 머지된 뒤에야 드러났다. merge-tree 에 # 전체 히스토리가 필요해 별도 job 으로 두고 checkout 을 fetch-depth 0 으로 받는다. @@ -63,9 +59,7 @@ jobs: node --test \ .github/scripts/label-conflicting-prs.test.mjs \ .github/scripts/label-android-test-plan.test.mjs \ - .github/scripts/label-issues-with-open-prs.test.mjs \ - .github/scripts/label-awaiting-author-prs.test.mjs \ - .github/scripts/awaiting-author-policy.test.mjs + .github/scripts/label-issues-with-open-prs.test.mjs - name: Reconcile pull request labels env: @@ -79,7 +73,6 @@ jobs: node .github/scripts/label-conflicting-prs.mjs || failures=1 node .github/scripts/label-android-test-prs.mjs || failures=1 node .github/scripts/label-issues-with-open-prs.mjs || failures=1 - node .github/scripts/label-awaiting-author-prs.mjs || failures=1 exit "$failures" sibling-conflicts: diff --git a/.github/workflows/latest-review-decision-event.yml b/.github/workflows/latest-review-decision-event.yml deleted file mode 100644 index ee0263767e..0000000000 --- a/.github/workflows/latest-review-decision-event.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Latest Review Decision Event - -# pull_request_review는 fork PR의 read-only 토큰으로도 발생한다. 여기서는 PR 코드나 API를 -# 건드리지 않고 완료 신호만 만든다. 실제 쓰기는 trusted default-branch workflow가 맡는다. -on: - pull_request_review: - types: [submitted] - -permissions: {} - -jobs: - signal: - name: Signal latest review decision reconciliation - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - name: Record review decision event - run: echo 'A pull request review decision was submitted.' diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml deleted file mode 100644 index 270dfc1ece..0000000000 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Latest Review Decision Reconcile - -on: - workflow_run: - workflows: [Latest Review Decision Event] - types: [completed] - workflow_dispatch: - inputs: - pull_number: - description: Optional pull request number; empty reconciles every open PR - required: false - type: string - -concurrency: - group: latest-review-decision-${{ github.repository }} - cancel-in-progress: false - -permissions: {} - -jobs: - reconcile: - name: Reconcile latest review decisions - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.event == 'pull_request_review' && - github.event.workflow_run.conclusion == 'success') - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: write - steps: - - name: Clone trusted default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Reconcile latest review decisions - env: - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_TOKEN: ${{ github.token }} - TARGET_PULL_NUMBER: >- - ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} - run: node .github/scripts/reconcile-latest-review-decision.mjs diff --git a/.github/workflows/pr-assign-author.yml b/.github/workflows/pr-assign-author.yml index 9e8f5c72e5..b72aa9ff57 100644 --- a/.github/workflows/pr-assign-author.yml +++ b/.github/workflows/pr-assign-author.yml @@ -5,7 +5,7 @@ # 어사인이 비어 있는 PR 에만 작성자를 건다. 사람이 담당자를 바꾼 것(작성자를 떼고 # 다른 사람을 건 것)은 그대로 둔다 — 강제하는 것은 «비어 있지 않음» 이지 «작성자» 가 # 아니다. 전부 떼어 비게 되면 다시 작성자를 건다. -# review-request-all 과 같은 조건(봇·외부 기여자 제외)으로 돈다. +# 봇·외부 기여자 PR 은 건너뛴다. name: pr-assign-author on: diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml deleted file mode 100644 index f64c37a3c8..0000000000 --- a/.github/workflows/review-debt-guard.yml +++ /dev/null @@ -1,428 +0,0 @@ -# 리뷰 적체 가드 (#1136) -# -# 리뷰 요청을 처리하지 않거나 자기 PR 의 변경요청에 응답하지 않은 사람이 새 PR 을 -# 계속 여는 것을 막는다. -# GitHub 네이티브에는 PR 생성을 막는 수단이 없어(ruleset·branch protection 은 -# push·merge 만 통제) 열린 직후 조회해 미처리 리뷰 요청이 1건이라도 있으면 -# 그 PR 을 닫는다. 브랜치는 남으므로 리뷰를 끝낸 뒤 되살릴 수 있다. -# -# 리뷰어 빚의 기준은 «나에게 걸린 리뷰 요청» 이 아니라 «아무도 응답하지 않은 열린 -# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 담당자가 그 리뷰어에게 명시적으로 -# 재요청해야만 공이 돌아온다. 수정 커밋만으로 리뷰 의무를 만들지는 않는다(#1136). -# 작성자 빚은 같은 사람이 담당하는 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답· -# 본문 편집 없이 남은 상태다. 라벨 갱신 시점에 기대지 않고 새 PR 을 열 때 현재 상태를 -# 다시 계산한다(#1679, #1700). -# -# PR 의 담당자는 «올린 사람» 이 아니라 «어사인된 사람» 이다(#1974 결정, #1980). 변경요청에 -# 응답하는 것도, «자기 PR» 을 리뷰어 빚에서 빼는 것도 어사인 기준이고, 어사인이 비어 있는 -# 동안만 작성자가 담당자다. 새 PR 을 여는 사람(AUTHOR)이 게이트를 받는 것은 그대로다. -name: review-debt-guard - -on: - pull_request: - types: [opened, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: write - -jobs: - debt-check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # 게이트 스크립트는 **default branch 사본**으로 돌린다. PR 사본을 체크아웃하면 PR 이 - # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. 필요한 건 .github/scripts 뿐이라 - # sparse-checkout 으로 좁힌다. - - name: Clone gate scripts from the default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - sparse-checkout: .github/scripts - - # 한도가 바닥인 채로 들어가면 게이트가 코드와 무관하게 빨개진다. 남은 quota 를 요약에 - # 남기고, 부족하면 리셋까지 기다린다 — 즉시 실패시키면 사람이 재실행을 걸고 그 재실행이 - # 남은 quota 를 더 태운다 (#1465). - - name: Ensure GitHub API quota - env: - GH_TOKEN: ${{ github.token }} - run: | - # default branch 사본에 이 스크립트가 아직 없는 동안(= 스크립트를 심는 이 PR 자신)에는 - # 건너뛴다. 머지 뒤에는 항상 존재하므로 이 경로는 부트스트랩 한 번에만 쓰인다. - if [ ! -f .github/scripts/ensure-api-quota.mjs ]; then - echo "::notice::quota 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - exit 0 - fi - # 두 자원을 순차 확인하므로 각각의 대기 상한을 절반 이하로 잡아 job 의 5분 - # timeout 안에 판정 로그까지 남긴다(내부 +5초 경계 여유를 포함해 최대 240초). - node .github/scripts/ensure-api-quota.mjs ensure --resource core --max-wait 115 - node .github/scripts/ensure-api-quota.mjs ensure --resource graphql --max-wait 115 - - - name: 리뷰·작성자 응답 빚 검사 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR: ${{ github.event.pull_request.user.login }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - # PR 제한·awaiting-author 라벨의 공통 면제 목록 (#1910, #1934). - # 라벨러도 이 선언을 읽는다. 인용·주석 없는 한 줄에서 공백으로 구분한다. - REVIEW_GATE_EXEMPT_AUTHORS: koongmai - run: | - # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 - # 얻지 못한 상태로 남의 PR 을 닫지도 않는다 — 아래에서 명시적으로 나눈다. - set -euo pipefail - - # 봇 PR 은 대상이 아니다. - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # fork PR 은 대상이 아니다 — 토큰이 read-only 라 닫지도 못하고, 팀 리뷰 - # 적체와도 무관하다. 원래 걸러내려던 것이 이것이므로 head 저장소로 가른다. - if [ "$HEAD_REPO" != "$REPO" ]; then - echo "fork PR($HEAD_REPO) — 건너뜀" - exit 0 - fi - - # 팀원 여부는 author_association 으로 가리지 않는다. 웹훅 페이로드는 조직 - # 멤버십이 비공개면 MEMBER 대신 CONTRIBUTOR 를 내리는데 이 조직은 공개 - # 멤버가 0명이다. 그래서 레포에 직접 초대된 한 계정만 COLLABORATOR 로 찍혀 - # 검사를 받고, 나머지 팀원은 전원 «외부 기여자» 로 건너뛰어졌다 — 8/25 에 - # #1152·#1154 가 그렇게 통과했고 12분 뒤 같은 빚으로 #1112 만 닫혔다. - # REST 로 조회하면 MEMBER 로 보여 눈으로는 잡히지 않는다. 실권한을 묻는다. - if ! perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq '.permission'); then - echo "::error::권한 조회에 실패했다 — 근거 없이 통과시키지 않는다. 재실행할 것" - exit 1 - fi - - # 빈 값·예상 밖 값을 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. - case "$perm" in - admin|maintain|write) ;; - read|none) echo "쓰기 권한 없음($perm) — 건너뜀"; exit 0 ;; - *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; - esac - - # 이 가드 자체를 면제받은 작성자 (#1910). 팀이 koongmai 에게는 PR 을 여는 시점의 - # 제약을 두지 않고 머지 시점 제약만 남기기로 했다. 리뷰 지적을 반영했는지와 무관하게 - # 새 PR 을 열 수 있고, 남의 PR 을 리뷰할 의무도 없어 review-request-all 의 TEAM 에서도 - # 빠져 있다. 머지 게이트는 그대로다. develop 은 여전히 승인 1건과 필수 체크를 요구하고, - # 그 요건은 이 가드가 아니라 required-approval·required-checks 룰셋이 건다. - # - # 면제는 "빚 계산" 앞에 둔다. 뒤에 두면 면제받은 사람의 PR 때문에 조회가 실패했을 때 - # 가드가 그를 대신해 죽는다. GitHub 로그인은 대소문자를 가리지 않으므로 낮춰 비교한다. - author_lc=$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]') - exempt_lc=$(printf '%s' "$REVIEW_GATE_EXEMPT_AUTHORS" | tr '[:upper:]' '[:lower:]') - case " $exempt_lc " in - *" $author_lc "*) echo "리뷰 게이트 면제 작성자($AUTHOR) — 건너뜀"; exit 0 ;; - esac - - # 빚의 기준은 «나에게 요청이 걸렸는가» 가 아니라 «아무도 응하지 않은 PR 이 - # 남아 있는가» 다. 리뷰 요청은 알림 수단이지 담당 배정이 아니다 — - # 누구에게 걸렸든 팀에서 한 사람이 보면 그 PR 의 리뷰는 끝난 것이다. - # 그래서 요청 목록이 아니라 열린 PR 전체를 훑는다. - # - # 자기 PR 은 뺀다 — «자기» 는 담당자(어사인, 비어 있으면 작성자) 기준이다. 남이 - # 올렸어도 내게 어사인된 PR 은 내 것이고, 내가 올렸어도 남에게 어사인된 PR 은 남의 - # 것이라 내가 리뷰할 수 있다. draft 는 리뷰를 받을 상태가 아니므로 뺀다 - # (review-request-all 도 draft 에는 요청을 걸지 않는다). 담당자 목록은 소문자 - # 쉼표 구분으로 실어 아래 반영·응답 집계의 신원으로 쓴다. - if ! open_prs=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate \ - --jq ".[] | select(.draft == false) - | ((if (.assignees | length) > 0 then [.assignees[].login] else [.user.login] end) | map(ascii_downcase)) as \$owners - | select((\$owners | index(\"$author_lc\")) == null) - | \"\(.number)\t\(.created_at[0:10])\t\(\$owners | join(\",\"))\t\([.requested_reviewers[].login | ascii_downcase] | join(\",\"))\t\(.title)\""); then - echo "::error::열린 PR 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다. 재실행할 것" - exit 1 - fi - repo_owner=${REPO%%/*} - repo_name=${REPO#*/} - - # 같은 작성자의 다른 PR 에 공이 남아 있는지도 실시간으로 다시 계산한다. 기존 - # `awaiting-author` 라벨만 보면 리컨사일 주기와 이 실행이 엇갈릴 때 방금 붙거나 - # 떨어진 상태를 놓친다. 라벨 리컨사일러와 같은 judgeAwaitingAuthor 술어를 쓰되, - # 현재 막 열려 판정 리뷰가 없는 PR 자신은 번호로 명시적으로 제외한다. - author_debt_file=$(mktemp) - if [ -f .github/scripts/check-author-debt.mjs ]; then - GITHUB_TOKEN="$GH_TOKEN" \ - GITHUB_REPOSITORY="$REPO" \ - AUTHOR="$AUTHOR" \ - CURRENT_PULL_REQUEST_NUMBER="$PR_NUMBER" \ - AUTHOR_DEBT_FILE="$author_debt_file" \ - node .github/scripts/check-author-debt.mjs - else - # default branch 에 스크립트를 심는 이 PR 자신에게는 아직 사본이 없다. 머지 - # 뒤에는 항상 존재하므로 부트스트랩 한 번만 작성자 빚 검사를 건너뛴다. - echo "::notice::작성자 빚 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - : > "$author_debt_file" - fi - - # 남은 것에서 «리뷰어의 공이 아닌» PR 을 걸러낸다. «누가» 봤는지는 따지지 - # 않는다 — 팀에서 한 사람이 더 늦은 판정을 내리면 그 판정이 PR 의 현재 - # 상태다. 더 오래된 다른 리뷰어의 변경요청을 다시 빚으로 살리지 않는다. - # - # 승인과 변경요청은 무게가 다르다. - # APPROVED 머지 가능한 상태다. 그 뒤에 작성자가 손보는 것은 - # 작성자 몫이고, 다시 봐 줄 의무는 누구에게도 없다. - # CHANGES_REQUESTED 아직 끝난 판정이 아니다. 다만 수정 커밋만으로 리뷰 - # 의무가 생기지는 않는다. 작성자가 같은 리뷰어에게 명시적으로 - # 재요청해야 공이 돌아온다. - # - # 그래서 빚은 두 종류다. (1) 아무도 판정을 내리지 않은 PR, - # (2) PR 전체의 최신 판정이 변경요청이고, 그 리뷰어에게 재요청이 걸렸으며, - # 그 판정 뒤 작성자가 반영 커밋·응답·PR 본문 편집을 남긴 PR. 재요청이 없거나 - # 최신 변경요청 뒤 작성자 조치가 없으면 빚이 아니다. 반영 커밋은 병합 커밋 - # (parents 2개 이상)을 뺀 실질 커밋으로만 센다. - debt_file=$(mktemp) - reviews_file=$(mktemp) - while IFS=$'\t' read -r pn created pr_owners requested_reviewers title; do - [ -n "$pn" ] || continue - - if ! gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ - --jq '.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED") - | {u: .user.login, s: .state, t: .submitted_at}' \ - > "$reviews_file"; then - echo "::error::PR #$pn 리뷰 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if [ ! -s "$reviews_file" ]; then - echo "#$pn — 아무도 판정하지 않음" - printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" - continue - fi - - # PR 전체의 최신 판정만 본다. 그 판정이 CHANGES_REQUESTED 이면서 현재 같은 - # 리뷰어에게 요청이 다시 걸렸다면, 리뷰 제출 시 제거된 요청을 작성자가 - # 최신 판정 뒤 명시적으로 되살린 것이다. 이후 다른 리뷰어가 더 늦게 판정한 - # 경우에는 과거 리뷰어의 남은 요청으로 공을 되돌리지 않는다. - if ! blocked=$(jq -rs --arg active ",$requested_reviewers," ' - sort_by(.t) | last - | .u |= ascii_downcase - | select(.s == "CHANGES_REQUESTED") - | select(.u as $reviewer - | $active | contains("," + $reviewer + ",")) - | [.u, .t] | @tsv' \ - "$reviews_file"); then - echo "::error::PR #$pn 최신 리뷰 판정에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if [ -z "$blocked" ]; then - echo "#$pn — 결정 리뷰 뒤 명시적 재리뷰 요청 없음, 빚 아님" - continue - fi - IFS=$'\t' read -r blocked_by blocked_at <<< "$blocked" - - # 면제 리뷰어가 낸 변경요청은 팀의 빚으로 돌아오지 않는다 (#1910). 면제된 사람은 - # 리뷰할 의무가 없으므로, 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 대가를 - # 나머지 팀원이 "새 PR 을 못 연다" 로 치르게 된다. 여기서 세지 않아도 그 PR 이 - # 그냥 머지되지는 않는다. 승인 1건이 머지 게이트에 그대로 남아 있다. - # jq 의 최신 판정은 이미 소문자로 내려온다. - case " $exempt_lc " in - *" $blocked_by "*) - echo "#$pn — 면제 리뷰어 @$blocked_by 의 변경요청, 팀 빚 아님" - continue - ;; - esac - - # 반영을 비병합 커밋으로만 세면 병합으로 반영한 PR 을 놓친다. base 를 - # 끌어와 충돌을 풀며 지적을 반영하면 그 뒤 커밋이 전부 parents 2개라 - # «미반영» 이 된다 — #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 - # 응답 코멘트까지 2건 달린 채로 8/29 까지 빚에서 빠져 있었다(#1450). - # 병합 커밋의 first-parent diff 는 base 에서 들어온 변경을 통째로 담아 - # 반영분과 구별되지 않으므로, 커밋 대신 «작성자가 응답했는가» 를 함께 본다. - # - # 페이지마다 집계하면 100건이 넘는 PR 에서 숫자가 여러 줄로 나와 비교가 - # 깨진다. 건별로 뽑아 한 번에 센다. - # - # 세는 것은 «작성자가 올린 실질 변경» 뿐이다. 누가 올렸는지·무엇이 바뀌었는지를 - # 안 보면 리뷰어가 미는 CI 재트리거 빈 커밋까지 «작성자가 반영했다» 로 세어, - # 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열게 된다 — 8/28 에 리뷰어가 건 - # +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 전부 가짜 빚이 됐다(#1459). - commits_file=$(mktemp) - if ! gh api "repos/$REPO/pulls/$pn/commits?per_page=100" --paginate \ - --jq '.[] | select((.parents | length) < 2) - | [.sha, (.author.login // ""), (.commit.author.email // ""), .commit.committer.date] - | @tsv' > "$commits_file"; then - echo "::error::PR #$pn 커밋 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - # 계정이 연결되지 않은 커밋은 .author 가 비어 login 으로 가릴 수 없다. 같은 PR 에서 - # 담당자 것으로 확인된 커밋의 이메일을 모아 폴백 신원으로 쓴다. 담당자는 여럿일 수 - # 있으므로 집합으로 들고 다닌다. - author_emails=$(awk -F'\t' -v owners="$pr_owners" ' - BEGIN { - n = split(owners, list, ",") - for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 - } - (tolower($2) in target) && $3 != "" { print tolower($3) }' "$commits_file" | sort -u) - - author_commits=$(awk -F'\t' -v owners="$pr_owners" -v cutoff="$blocked_at" -v emails="$author_emails" ' - BEGIN { - n = split(owners, list, ",") - for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 - count = split(emails, list, "\n") - for (i = 1; i <= count; i++) if (list[i] != "") own[list[i]] = 1 - } - $4 > cutoff { - login = tolower($2) - email = tolower($3) - if (login != "") { if (login in target) print $1 } - else if (email != "" && (email in own)) print $1 - }' "$commits_file") - - # 빈 커밋은 반영이 아니다. pulls/{n}/commits 응답에는 파일 정보가 없어 후보에 - # 한해 commits/{sha} 를 더 조회한다 — 후보는 대개 한 자릿수라 비용이 작다. - # 조회 실패를 반영·미반영 어느 쪽으로도 접지 않는다. 불완전한 근거로 남의 PR 을 - # 닫지 않고 가드를 실패시켜 재실행하게 한다. - fixed=0 - while IFS= read -r sha; do - [ -n "$sha" ] || continue - if ! changed=$(gh api "repos/$REPO/commits/$sha" --jq '(.files // []) | length'); then - echo "::error::PR #$pn 커밋 $sha 변경 파일 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - case "$changed" in - ''|*[!0-9]*) - echo "::error::PR #$pn 커밋 $sha 변경 파일 수가 올바르지 않다($changed)" - exit 1 - ;; - esac - [ "$changed" -gt 0 ] || continue - fixed=$((fixed + 1)) - done <<< "$author_commits" - - # 응답 주체도 담당자 집합이다. 쉼표로 감싼 목록에 쉼표로 감싼 로그인이 들어 있는지 본다. - if ! said=$(gh api "repos/$REPO/issues/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) - | select(.created_at > \"$blocked_at\") | .id" \ - | wc -l | tr -d ' '); then - echo "::error::PR #$pn 작성자 일반 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if ! answered=$(gh api "repos/$REPO/pulls/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) - | select(.created_at > \"$blocked_at\") | .id" \ - | wc -l | tr -d ' '); then - echo "::error::PR #$pn 작성자 리뷰 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - # PR 본문은 커밋 없이도 CI 계약·설명을 고치는 변경 표면이다(#771). REST 의 - # updated_at 은 댓글·라벨까지 섞이고 GraphQL lastEditedAt 은 마지막 편집자만 - # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 담당자가 직접 고친 본문»만 센다. - # 최근 50건보다 많은 편집 이력이 있으면 필요한 구간이 잘렸을 수 있으므로 - # 근거 없이 작성자·리뷰어 어느 쪽으로도 공을 넘기지 않고 가드를 실패시킨다. - # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -F 값으로 바인딩한다. - if ! edits_json=$(gh api graphql \ - -F owner="$repo_owner" \ - -F name="$repo_name" \ - -F number="$pn" \ - -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){userContentEdits(last:50){pageInfo{hasPreviousPage} nodes{editedAt editor{login}}}}}}'); then - echo "::error::PR #$pn 본문 편집 이력 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - if ! body_edits=$(jq -er \ - --arg owners ",$pr_owners," \ - --arg cutoff "$blocked_at" ' - .data.repository.pullRequest.userContentEdits as $edits - | if ($edits.pageInfo.hasPreviousPage != false) then - error("최근 50건보다 많은 본문 편집 이력") - else - [ - $edits.nodes[] - | select(((.editor.login // "") | ascii_downcase) as $login | $owners | contains("," + $login + ",")) - | select(.editedAt > $cutoff) - ] | length - end - ' <<< "$edits_json"); then - echo "::error::PR #$pn 본문 편집 이력이 불완전하다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - responses=$(( ${said:-0} + ${answered:-0} )) - - if [ "${fixed:-0}" -gt 0 ] || [ "$responses" -gt 0 ] || [ "$body_edits" -gt 0 ]; then - echo "#$pn — @$blocked_by 재리뷰 요청 뒤 변경요청 반영(${fixed}커밋 · 응답 ${responses}건 · 본문 편집 ${body_edits}건), 아직 미응답" - printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" - else - echo "#$pn — 변경요청 미반영, 공은 담당자에게" - fi - done <<< "$open_prs" - - if [ ! -s "$debt_file" ] && [ ! -s "$author_debt_file" ]; then - echo "리뷰·작성자 응답을 기다리는 PR 없음 — 통과" - exit 0 - fi - - debt=$(cat "$debt_file") - author_debt=$(cat "$author_debt_file") - review_count=$(wc -l < "$debt_file" | tr -d ' ') - author_count=$(wc -l < "$author_debt_file" | tr -d ' ') - count=$(( review_count + author_count )) - today=$(date -u +%s) - - { - echo "## 🚧 리뷰 적체 가드 — 이 PR 을 닫습니다" - echo - if [ "$review_count" -gt 0 ]; then - echo "### 팀 리뷰 응답 대기 · ${review_count}건" - echo - echo "| PR | 열린 날 | 경과 | 제목 |" - echo "|---|---|---|---|" - while IFS=$'\t' read -r n created title; do - [ -n "$n" ] || continue - opened=$(date -u -d "$created" +%s 2>/dev/null || echo "$today") - days=$(( (today - opened) / 86400 )) - echo "| #$n | $created | ${days}일 | $title |" - done <<< "$debt" - echo - fi - if [ "$author_count" -gt 0 ]; then - echo "### 작성자 응답 대기 · ${author_count}건" - echo - echo "| PR | 열린 날 | 변경요청자 | 제목 |" - echo "|---|---|---|---|" - while IFS=$'\t' read -r n created reviewer title; do - [ -n "$n" ] || continue - echo "| #$n | $created | @$reviewer | $title |" - done <<< "$author_debt" - echo - fi - echo "이 저장소는 팀 리뷰 응답 또는 자기가 담당하는 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." - echo - echo "PR 의 담당자는 올린 사람이 아니라 **어사인된 사람**입니다(어사인이 비어 있으면 작성자, #1974)." - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 담당자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." - echo "담당하는 PR 이 **최신 변경요청 뒤 실질 커밋·응답·본문 편집 없이** 남아 있으면 담당자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." - echo - echo "### 되살리는 법" - echo - echo "위 PR 에 필요한 리뷰나 작성자 조치를 끝낸 뒤 이 PR 을 다시 여세요. **브랜치는 지워지지 않았고 커밋도 그대로입니다.**" - echo - echo '```bash' - echo "gh pr reopen $PR_NUMBER" - echo '```' - } > /tmp/debt-comment.md - - echo "리뷰·작성자 미응답 ${count}건 — PR #$PR_NUMBER 를 닫는다" - gh pr close "$PR_NUMBER" --repo "$REPO" --comment "$(cat /tmp/debt-comment.md)" - - # 실패가 «가드가 사실을 확인하고 거절함» 인지 «한도 소진» 인지 가른다. 구분이 없으면 - # 빨간 체크를 보고도 원인을 로그까지 들어가야 안다 (#1465). - - name: Classify a failed gate as rate limit exhaustion - if: failure() - run: | - # default branch 사본에 이 스크립트가 아직 없는 동안(= 스크립트를 심는 이 PR 자신)에는 - # 건너뛴다. 머지 뒤에는 항상 존재하므로 이 경로는 부트스트랩 한 번에만 쓰인다. - if [ ! -f .github/scripts/ensure-api-quota.mjs ]; then - echo "::notice::quota 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - exit 0 - fi - node .github/scripts/ensure-api-quota.mjs classify --resource core - node .github/scripts/ensure-api-quota.mjs classify --resource graphql - env: - GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/review-request-all.yml b/.github/workflows/review-request-all.yml deleted file mode 100644 index cbcba2ebe2..0000000000 --- a/.github/workflows/review-request-all.yml +++ /dev/null @@ -1,206 +0,0 @@ -# 리뷰어 자동 요청 (#1136) -# -# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 담당자를 뺀 팀 전원에게 -# 자동으로 요청한다. GitHub 은 자기 PR 의 작성자를 리뷰어로 받지 않으므로 -# 목록에 전원을 적어도 작성자는 자동으로 빠진다. 담당자(어사인)는 GitHub 이 막지 -# 않으므로 여기서 직접 뺀다 — PR 의 공은 올린 사람이 아니라 어사인된 사람에게 -# 있다(#1974 결정, #1980). 어사인이 비어 있는 동안만 작성자가 담당자다. -# -# 이 워크플로는 review-debt-guard 와 한 세트다. 요청이 자동으로 걸려야 -# "리뷰를 안 하면 새 PR 을 못 연다" 가 성립한다 — 요청을 손으로 걸던 때는 -# 아예 걸지 않는 것으로 가드를 통과할 수 있었다. -# -# 같은 구멍이 «변경요청 뒤» 에 그대로 남아 있었다(#1450). GitHub 은 변경요청 -# 리뷰를 제출하는 순간 그 리뷰어의 요청을 떼어 내는데, 가드는 그 리뷰어에게 -# 요청이 다시 걸린 경우에만 빚으로 센다. 되살리는 일이 작성자 손에 맡겨져 있어 -# 8/29 실측에서 반영까지 끝난 PR 7건이 전원 «빚 아님» 이었고, 그중 하나는 -# dependabot 이 작성자라 되살릴 주체 자체가 없었다. 반영 커밋이 올라오거나 -# 작성자가 PR 본문을 고치면 재요청도 자동으로 건다. -# -# 옛 Review Assign(.github/workflows/PRassign.yml)을 대체한다. 그쪽은 develop -# 에서 파일만 지워졌을 뿐 워크플로 등록이 살아 있어, 파일이 남은 옛 브랜치에서 -# 갈라진 PR 에는 지금도 구버전이 실행된다. 이 워크플로를 들이면서 함께 disable -# 해야 중복 요청이 나지 않는다. -name: review-request-all - -on: - pull_request: - types: [opened, ready_for_review, reopened, synchronize, edited] - -permissions: - pull-requests: write - -jobs: - request: - # draft 에는 걸지 않는다. 리뷰할 수 없는 PR 로 남의 미처리 건수를 늘리면 - # review-debt-guard 가 그 사람의 새 PR 을 닫아 버린다. - if: >- - github.event.pull_request.state == 'open' && - github.event.pull_request.draft == false && - github.event.action != 'synchronize' && - github.event.action != 'edited' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: 담당자를 뺀 전원에게 리뷰 요청 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR: ${{ github.event.pull_request.user.login }} - ASSOCIATION: ${{ github.event.pull_request.author_association }} - # 이벤트 시점의 어사인. pr-assign-author 가 같은 이벤트에서 빈 어사인에 작성자를 - # 거는 중일 수 있으므로, 비어 있으면 작성자로 폴백한다. - ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} - # 리뷰 의무가 있는 팀원만 적는다. koongmai 는 남의 PR 을 리뷰할 의무가 없어 - # 빠져 있다 (#1910). 자기 PR 에는 나머지 둘에게 그대로 요청이 걸리고, - # 스스로 리뷰하는 것도 막히지 않는다. 빠진 것은 자동 요청뿐이다. - TEAM: 1hyok Sadturtleman - run: | - set -euo pipefail - - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # 외부 기여자 PR 은 토큰이 read-only 라 요청을 걸 수 없다. - case "$ASSOCIATION" in - OWNER|MEMBER|COLLABORATOR) ;; - *) echo "외부 기여자($ASSOCIATION) — 건너뜀"; exit 0 ;; - esac - - # 이미 걸려 있는 요청은 다시 걸지 않는다. reopened·ready_for_review 가 - # 여러 번 오갈 때 같은 사람에게 요청이 반복되는 것을 막는다. - current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ - --jq '[.requested_reviewers[].login] | join(" ")') - - # 담당자는 리뷰어가 아니다. 어사인이 있으면 어사인, 없으면 작성자. 로그인은 - # 대소문자를 가리지 않으므로 낮춰 비교한다. - owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') - current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') - - targets="" - for u in $TEAM; do - u_lc=$(printf '%s' "$u" | tr '[:upper:]' '[:lower:]') - case " $owners_lc " in - *" $u_lc "*) continue ;; - esac - case " $current_lc " in - *" $u_lc "*) continue ;; - esac - targets="${targets:+$targets,}$u" - done - - if [ -z "$targets" ]; then - echo "추가할 리뷰어 없음 (이미 요청됨 또는 담당자뿐)" - exit 0 - fi - - echo "리뷰 요청: $targets" - gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$targets" - - # 변경요청 뒤 반영 커밋이 올라오거나 담당자가 PR 본문을 고치면 그 리뷰어에게 - # 다시 요청을 건다(#1450, #1700). `edited` 전체를 받되 제목 수정·관리자 대리 수정은 - # 담당자 조치가 아니므로 body 변경과 sender=담당자 를 함께 요구한다. 담당자는 - # 어사인이고, 어사인이 비어 있으면 작성자다(#1974). - # - # 판정 기준은 review-debt-guard 와 같아야 한다 — 여기서 거는 요청이 곧 그쪽의 - # 빚 성립 조건이므로, 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 - # 이어지지 않거나 이미 승인된 PR 을 되살린다. - rerequest: - if: >- - github.event.pull_request.state == 'open' && - github.event.pull_request.draft == false && - ( - github.event.action == 'synchronize' || - ( - github.event.action == 'edited' && - github.event.changes.body != null && - ( - contains(github.event.pull_request.assignees.*.login, github.event.sender.login) || - ( - github.event.pull_request.assignees[0] == null && - github.event.sender.login == github.event.pull_request.user.login - ) - ) - ) - ) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: 변경요청 리뷰어에게 재요청 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR: ${{ github.event.pull_request.user.login }} - ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - ACTION: ${{ github.event.action }} - EVENT_AT: ${{ github.event.pull_request.updated_at }} - run: | - set -euo pipefail - - # 봇 PR 은 건너뛴다. dependabot 이 연 PR 에서는 GITHUB_TOKEN 이 read-only 라 - # 요청을 걸지 못한다 — 시도하면 403 으로 죽을 뿐이다. 봇 PR 의 변경요청이 - # 빚이 되지 못하는 구멍은 이 워크플로로는 못 메운다(#1450 에 남겨 둔다). - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # fork PR 도 토큰이 read-only 다. 팀원 여부를 author_association 으로 가르지 - # 않는 이유는 review-debt-guard 주석에 있다 — 비공개 멤버가 CONTRIBUTOR 로 - # 내려와 팀원 대부분이 조용히 빠진다. 실제 제약인 head 저장소로 가른다. - if [ "$HEAD_REPO" != "$REPO" ]; then - echo "fork PR($HEAD_REPO) — 토큰이 read-only 라 건너뜀" - exit 0 - fi - if [ "$ACTION" = "edited" ] && [ -z "$EVENT_AT" ]; then - echo "::error::본문 편집 이벤트 시각이 없다 — 과거 편집을 최신 변경요청에 귀속하지 않도록 재실행할 것" - exit 1 - fi - - # PR 전체의 최신 판정만 본다. 승인 뒤 올라온 커밋은 재요청 대상이 아니다. - # --paginate 는 페이지마다 jq 를 돌리므로 정렬은 모아서 한 번에 한다. - if ! decisions=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" --paginate \ - --jq '.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED") - | {u: .user.login, s: .state, t: .submitted_at}'); then - echo "::error::리뷰 조회에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것" - exit 1 - fi - if ! blocked=$(printf '%s\n' "$decisions" \ - | jq -rs --arg action "$ACTION" --arg event_at "$EVENT_AT" ' - sort_by(.t) | last // empty - | select(.s == "CHANGES_REQUESTED") - | select($action != "edited" or .t < $event_at) - | [.u, .t] | @tsv - '); then - echo "::error::최신 리뷰 판정에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것" - exit 1 - fi - - if [ -z "$blocked" ]; then - echo "최신 판정이 변경요청이 아님 — 재요청할 대상 없음" - exit 0 - fi - IFS=$'\t' read -r blocked blocked_at <<< "$blocked" - - # 자기 자신은 리뷰어가 될 수 없다(422). 작성자가 자기 PR 에 변경요청을 낼 - # 수는 없지만, 소유권이 옮겨진 PR 에서 그렇게 보일 수 있다. 담당자(어사인)가 - # 낸 변경요청도 자기 PR 에 대한 것이라 되살리지 않는다. - owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') - blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]') - case " $owners_lc " in - *" $blocked_lc "*) echo "변경요청을 낸 사람이 담당자 본인(@$blocked) — 건너뜀"; exit 0 ;; - esac - - # 이미 걸려 있으면 다시 걸지 않는다. synchronize 는 force-push 마다 온다. - current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ - --jq '[.requested_reviewers[].login] | join(" ")') - current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') - case " $current_lc " in - *" $blocked_lc "*) echo "@$blocked 요청이 이미 걸려 있음 — 건너뜀"; exit 0 ;; - esac - - echo "변경요청($blocked_at) 뒤 담당자 조치($ACTION) — @$blocked 에게 재리뷰 요청" - gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$blocked" diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 0c4c877e5d..0910b46a60 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -11,8 +11,7 @@ # 않으므로, 이 단계가 없으면 아무도 다시 보지 않은 커밋이 머지된다. 누가 건 예약인지는 따지지 않는다. 예약을 # 멈추려면 파일 하나의 Viewed 를 풀면 되고, 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. # -# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 -# 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN +# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN # 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. # # 예약 쓰기는 MERGE_QUEUE_TOKEN(1hyok 의 fine-grained PAT)이 한다. GITHUB_TOKEN 으로 예약하거나 큐에 넣으면 GitHub 이 @@ -23,9 +22,6 @@ name: Viewed Auto Merge on: schedule: - cron: '*/10 * * * *' - workflow_run: - workflows: [Latest Review Decision Reconcile] - types: [completed] workflow_dispatch: concurrency: diff --git a/README.md b/README.md index d308fb7f67..19453f492d 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,8 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 정기·기본 브랜치 수동 검증은 PR critical path 밖에서 minSdk API 26과 targetSdk API 36 경계 smoke도 실행한다. 문서 링크는 PR에서 로컬 경로·heading anchor를 검사하고 외부 URL은 주간 실행으로 분리하며, release AAB/R8 preflight는 `develop`에서 주 2회 조기 검증한다. -## 코드 리뷰 - -- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. -- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. -- 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. -- `develop` 대상 PR은 승인 상태에서 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. -- 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. -- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. -- `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. -- 쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다. 가장 늦은 판정이 승인이면 더 오래된 변경 요청은 자동 해제되고, 승인 뒤에 새 변경 요청이 오면 다시 차단된다. -- 적체 가드에는 라벨 우회가 없다. 가드와 `awaiting-author` 라벨이 함께 쓰는 면제 목록은 [review-debt-guard.yml](.github/workflows/review-debt-guard.yml)의 `REVIEW_GATE_EXEMPT_AUTHORS`이며, 지금은 `koongmai`가 거기에 있다. 면제받은 사람은 리뷰 지적 반영 여부와 무관하게 새 PR을 열 수 있고 남의 PR을 리뷰할 의무가 없다. 그래서 자동 요청 대상에서도 빠지고, 그가 낸 변경요청은 다른 팀원의 빚으로 세지 않는다. 머지 게이트는 면제와 무관하다. `develop` 승인 1건과 필수 체크는 그대로 요구된다. +## 머지 + +- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 건다. 사람이 담당자를 바꾼 것은 되돌리지 않는다. +- `develop`·`main` 머지에 리뷰 승인은 필요 없다. PR 경유, 필수 검사, `develop`의 merge queue는 그대로 요구된다. +- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. 변경요청이 남은 PR은 예약하지 않는다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. From 2f5cac84bb338bd3c054c4bef12bad907d94e4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 12:39:18 +0000 Subject: [PATCH 095/116] =?UTF-8?q?docs(release):=20Firebase=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9D=B8=EC=A6=9D=20=EC=84=9C=EC=88=A0=EC=9D=84=20?= =?UTF-8?q?WIF=20=EC=A0=84=ED=99=98=20=EB=92=A4=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20(#850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/distribution.md | 7 +++--- docs/release/firebase-wif-canary.md | 35 +++++++++++++++++------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/release/distribution.md b/docs/release/distribution.md index 7f5d608583..fc9e34a46f 100644 --- a/docs/release/distribution.md +++ b/docs/release/distribution.md @@ -79,7 +79,7 @@ PR 유형과 파일 단위 이력은 사용자 흐름의 의미를 완전히 표 기존 `release-distribution.yml`의 `develop` 수동 배포(`workflow_dispatch`)는 #1029에서 제거했다. 도착지와 산출물 버전이 main 경로와 같아 실익이 PR 생성 한 단계뿐이었던 반면, release keystore와 service account를 임의 ref에 노출하는 표면이었다. -예외적으로 [`firebase-wif-canary.yml`](../../.github/workflows/firebase-wif-canary.yml)은 `develop` 또는 `main`에서 보호 Environment 승인과 명시적 확인을 받은 뒤 실제 APK를 올리는 **수동 인증 호환성 검증 경로**다. 일반 릴리스나 임의 브랜치 배포 수단으로 사용하지 않으며, production JSON 자격을 WIF로 교체하기 전 판정 절차는 [WIF canary runbook](firebase-wif-canary.md)을 따른다. +예외적으로 [`firebase-wif-canary.yml`](../../.github/workflows/firebase-wif-canary.yml)은 `develop` 또는 `main`에서 보호 Environment 승인과 명시적 확인을 받은 뒤 실제 APK를 올리는 **수동 인증 호환성 검증 경로**다. 일반 릴리스나 임의 브랜치 배포 수단으로 사용하지 않는다. production `release-distribution.yml`도 #1463부터 같은 WIF 경로로 인증하고, 장기 JSON 키 경로는 #1566에서 걷었다. canary 실행 조건은 [WIF canary runbook](firebase-wif-canary.md)을 따른다. `develop` → `main` 릴리스 PR 본문에 다음 섹션을 채운다. @@ -106,15 +106,14 @@ CI가 사용하는 GitHub Secrets (Settings → Secrets and variables → Action | `RELEASE_STORE_PASSWORD` | keystore 비밀번호 | | `RELEASE_KEY_ALIAS` | key alias (`afternote-release`) | | `RELEASE_KEY_PASSWORD` | key 비밀번호 | -| `FIREBASE_SERVICE_ACCOUNT_JSON` | App Distribution Admin 권한 부여된 service account JSON 원문 | | `KAKAO_NATIVE_APP_KEY` · `GOOGLE_WEB_CLIENT_ID` · `GOOGLE_SERVICES_JSON_B64` | release distribution·WIF canary·dependency audit가 쓰는 실서비스 앱 설정 | -| `GCP_WORKLOAD_IDENTITY_PROVIDER` · `GCP_FIREBASE_SERVICE_ACCOUNT` | 수동 WIF canary 전용 OIDC 설정 ([runbook](firebase-wif-canary.md)) | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` · `GCP_FIREBASE_SERVICE_ACCOUNT` | release distribution과 WIF canary가 Firebase 업로드 직전에 쓰는 WIF 설정. 서비스 계정 `afternote-ci` 는 이 두 워크플로에만 묶여 있다([Play release runbook](../play-release.md) 사전 준비 3번) | > base64 인코딩: `base64 -i ~/afternote-release.jks | pbcopy` (macOS) PR 검증용 lint·unit-test·screenshot은 repository secret 대신 `.github/actions/setup-ci-config`가 만드는 결정적 CI 전용 placeholder를 사용한다. 이 fixture는 -배포에 사용할 수 없으며, production `release-distribution.yml`은 계속 승인된 환경의 JSON 자격을 사용한다. WIF canary 성공만으로 이를 제거하지 않는다. +배포에 사용할 수 없으며, production `release-distribution.yml`은 승인된 환경에서 WIF로 받은 단기 자격으로만 업로드한다. ### 배포 provenance: 이 APK 가 어느 commit·run 에서 나왔는지 (#851) diff --git a/docs/release/firebase-wif-canary.md b/docs/release/firebase-wif-canary.md index 1d7627d633..0450f1cfe4 100644 --- a/docs/release/firebase-wif-canary.md +++ b/docs/release/firebase-wif-canary.md @@ -1,30 +1,33 @@ # Firebase App Distribution WIF canary -이 문서는 기존 `FIREBASE_SERVICE_ACCOUNT_JSON` 인증을 즉시 교체하지 않고, 별도 수동 -canary에서 GitHub OIDC와 Google Cloud Workload Identity Federation 호환성을 먼저 확인하는 -절차다. 저장소 workflow만 준비하며 Google Cloud·GitHub Settings 변경과 실제 업로드 실행은 -별도 승인 범위다. +이 문서는 수동 canary에서 GitHub OIDC와 Google Cloud Workload Identity Federation으로 +Firebase App Distribution 업로드를 확인하는 절차다. 처음에는 장기 JSON 키를 WIF로 바꾸기 전의 +호환성 검증으로 만들었고, 전환은 끝났다. production `release-distribution.yml`은 #1463부터 +WIF로 인증하고, `FIREBASE_SERVICE_ACCOUNT_JSON` 롤백 경로는 #1566에서 걷었다. ## 현재 안전 경계 -- production `release-distribution.yml`은 기존 JSON 인증을 그대로 사용한다. +- production `release-distribution.yml`도 같은 WIF 설정으로 인증한다. JSON 키 경로는 없다. - canary는 `develop` 또는 `main`의 수동 실행과 `release-distribution` Environment 승인을 모두 요구한다. - signed APK는 인증 전에 빌드하고 같은 runner에서 바로 업로드한다. public Actions artifact로 게시하지 않는다. - WIF 인증은 업로드 직전에만 수행한다. provider·service account 값, token, 생성된 ADC 상세는 workflow summary나 별도 artifact에 기록하지 않는다. -- canary 실패 시 production workflow나 기존 JSON secret을 변경·삭제하지 않는다. +- canary 실패만으로 production workflow의 인증 설정을 바꾸지 않는다. -## 별도 승인이 필요한 외부 준비 +## 외부 준비 -다음 항목은 이 PR에서 만들거나 수정하지 않는다. +전환 때 갖춘 Google Cloud 쪽 준비다. provider와 서비스 계정 바인딩의 현재 값은 +[Play release runbook](../play-release.md) 사전 준비 3번에 있다. 1. GitHub OIDC용 workload identity pool/provider와 Firebase App Distribution 전용 최소 권한 service account를 준비한다. 2. provider attribute mapping에 `repository`, `repository_owner`, `ref`, `workflow_ref`, `environment` claim을 포함한다. -3. attribute condition은 최소한 다음을 동시에 제한한다. +3. attribute condition은 처음에 다음을 동시에 제한하도록 설계했다. 지금 condition은 `workflow_ref` + 허용 목록에 `release-distribution.yml`과 `release-play-internal.yml`도 들어 있다. 현재 값의 정본은 + Play release runbook 사전 준비 3번이다. - repository: `Afternote/Afternote-FE` - owner: `Afternote` - ref: `refs/heads/develop` 또는 `refs/heads/main` @@ -32,7 +35,11 @@ canary에서 GitHub OIDC와 Google Cloud Workload Identity Federation 호환성 - environment: `release-distribution` 4. 보호된 Environment에 `GCP_WORKLOAD_IDENTITY_PROVIDER`와 `GCP_FIREBASE_SERVICE_ACCOUNT`를 masked secret으로 등록한다. -5. 허용 branch canary 성공뿐 아니라 다른 branch·fork·workflow의 credential 발급 거부도 +5. Firebase 서비스 계정 `afternote-ci`의 `roles/iam.workloadIdentityUser`는 저장소 전체가 아니라 + `release-distribution.yml@refs/heads/main`, `firebase-wif-canary.yml@refs/heads/main`, + `firebase-wif-canary.yml@refs/heads/develop` 셋의 `attribute.workflow_ref`에만 준다. 같은 provider를 + Play 배포 워크플로도 통과하므로, 저장소 단위로 묶으면 그 워크플로가 Firebase 계정을 빌릴 수 있다. +6. 허용 branch canary 성공뿐 아니라 다른 branch·fork·workflow의 credential 발급 거부도 Google Cloud audit evidence로 확인한다. 공식 근거: @@ -42,14 +49,14 @@ canary에서 GitHub OIDC와 Google Cloud Workload Identity Federation 호환성 - [Firebase App Distribution CI/CD와 WIF credential configuration](https://firebase.google.com/docs/app-distribution/best-practices-distributing-android-apps-to-qa-testers-with-ci-cd) - [google-github-actions/auth](https://github.com/google-github-actions/auth) -## 전환 판정 +## 전환 판정 (완료) -JSON 경로 제거는 아래가 모두 PASS인 별도 후속 변경에서만 한다. +#1463·#1566 전환 때 쓴 기준이다. 인증 구성을 다시 바꿀 때도 같은 기준으로 확인한다. - 승인된 `develop` 또는 `main` canary APK가 WIF ADC로 1회 업로드된다. - Firebase release의 source SHA와 실행 URL이 해당 canary run과 일치한다. - 허용하지 않은 branch·fork·workflow의 credential 발급이 거부된다. - 대상 service account 권한이 Firebase App Distribution 업로드 최소 범위로 검토된다. -- 실패 canary 뒤 기존 production JSON 경로가 정상 동작한다. +- 실패 canary 뒤 production 배포 경로가 정상 동작한다. -하나라도 확인되지 않으면 상태는 `UNVERIFIED`이며 기존 JSON secret을 유지한다. +하나라도 확인되지 않으면 상태는 `UNVERIFIED`이며 production 인증 구성을 바꾸지 않는다. From 393add4876acdc9b34ac83e1ba2b79ee92dced14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 13:00:34 +0000 Subject: [PATCH 096/116] =?UTF-8?q?ci(platform):=20Viewed=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A8=B8=EC=A7=80=EA=B0=80=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=B3=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20(#2277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰를 폐지했으므로 변경 요청·승인 대기도 예약을 막지 않는다. 조회 필드에서 reviewDecision 을 뺀다. --- .../scripts/find-viewed-auto-merge-targets.mjs | 13 +++---------- .../find-viewed-auto-merge-targets.test.mjs | 16 +++++----------- .github/workflows/viewed-auto-merge.yml | 5 ++--- README.md | 2 +- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index 7087f07f61..ad697e5534 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -1,7 +1,7 @@ // Viewed 기반 자동 머지 대상 판정 (#2248) // -// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 -// «Merge when ready» 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크하면 «Merge when ready» 를 예약할 대상으로 +// 고른다. 리뷰 상태는 보지 않는다(#2277, 리뷰 폐지). 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 // 한다. // // viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 @@ -17,10 +17,8 @@ import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -const BLOCKING_REVIEW_DECISIONS = new Set(["CHANGES_REQUESTED", "REVIEW_REQUIRED"]); - const PULL_REQUEST_FIELDS = ` - id number isDraft headRefOid reviewDecision isInMergeQueue changedFiles + id number isDraft headRefOid isInMergeQueue changedFiles author { login } autoMergeRequest { enabledAt } files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } @@ -49,11 +47,6 @@ export function decideViewedAutoMerge(pullRequest, files) { if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; } - // 승인 룰셋이 승인 0건을 요구하면 GitHub 는 reviewDecision 을 비워(null) 돌려준다. 막는 것은 - // 아직 승인이 필요하거나 변경 요청이 남은 상태뿐이다. - if (BLOCKING_REVIEW_DECISIONS.has(pullRequest.reviewDecision)) { - return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision}` }; - } // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. if (files.length === 0 || files.length !== pullRequest.changedFiles) { return { enable: false, reason: `파일 ${files.length}/${pullRequest.changedFiles} 만 읽었다` }; diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index ac2f0d749a..8515ccbbc3 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -16,7 +16,6 @@ function pullRequest(overrides = {}) { number: 1, isDraft: false, headRefOid: "0123456789abcdef0123456789abcdef01234567", - reviewDecision: "APPROVED", isInMergeQueue: false, autoMergeRequest: null, changedFiles: 2, @@ -35,18 +34,13 @@ test("하나라도 Viewed 가 아니면 예약하지 않는다 — 체크 뒤 } }); -test("승인 대기·변경 요청이면 Viewed 를 다 해도 예약하지 않는다", () => { - for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED"]) { +test("리뷰 상태와 무관하게 Viewed 만으로 예약한다", () => { + for (const reviewDecision of [null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) { const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); - assert.equal(decision.enable, false, String(reviewDecision)); + assert.equal(decision.enable, true, String(reviewDecision)); } }); -test("승인을 요구하지 않는 룰셋(reviewDecision null)이면 Viewed 만으로 예약한다", () => { - const decision = decideViewedAutoMerge(pullRequest({ reviewDecision: null }), [viewed("a.kt"), viewed("b.kt")]); - assert.equal(decision.enable, true); -}); - test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않는다", () => { assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).enable, false); assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 0 }), []).enable, false); @@ -88,9 +82,9 @@ test("merge queue 에 있는데 Viewed 가 풀리면 큐에서 빼고, 예약도 ); }); -test("승인·Draft 상태와 무관하게 Viewed 로만 해제를 가른다", () => { +test("리뷰·Draft 상태와 무관하게 Viewed 로만 해제를 가른다", () => { const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: "UNVIEWED" }]; - for (const overrides of [{ reviewDecision: "APPROVED" }, { reviewDecision: "REVIEW_REQUIRED" }, { isDraft: true }]) { + for (const overrides of [{ reviewDecision: "APPROVED" }, { reviewDecision: "CHANGES_REQUESTED" }, { isDraft: true }]) { assert.equal(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, ...overrides }), files).disableAutoMerge, true); } }); diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 0910b46a60..4a89af9fc5 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -2,9 +2,8 @@ # # 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약도 해제도 하지 않는다. # -# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 «Merge when ready» 를 -# 대신 건다. 승인 룰셋이 승인 0건이면 reviewDecision 이 비어 있어도 대상이다. 머지는 GitHub 가 룰셋·required -# check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크하면 «Merge when ready» 를 대신 건다. 리뷰 상태는 보지 않는다. +# 머지는 GitHub 가 룰셋·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. # # 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 VIEWED_BY 본인 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 # 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 diff --git a/README.md b/README.md index 19453f492d..a30bebaa2a 100644 --- a/README.md +++ b/README.md @@ -187,4 +187,4 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 - PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 건다. 사람이 담당자를 바꾼 것은 되돌리지 않는다. - `develop`·`main` 머지에 리뷰 승인은 필요 없다. PR 경유, 필수 검사, `develop`의 merge queue는 그대로 요구된다. -- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. 변경요청이 남은 PR은 예약하지 않는다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. +- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. From cd4854754a1d06d9d05670596d4d4f43b6565dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 13:17:04 +0000 Subject: [PATCH 097/116] =?UTF-8?q?docs(release):=20WIF=20provider=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20gcloud=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4=20(#850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/release/firebase-wif-canary.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/release/firebase-wif-canary.md b/docs/release/firebase-wif-canary.md index 0450f1cfe4..ad1feafc26 100644 --- a/docs/release/firebase-wif-canary.md +++ b/docs/release/firebase-wif-canary.md @@ -23,16 +23,17 @@ WIF로 인증하고, `FIREBASE_SERVICE_ACCOUNT_JSON` 롤백 경로는 #1566에 1. GitHub OIDC용 workload identity pool/provider와 Firebase App Distribution 전용 최소 권한 service account를 준비한다. -2. provider attribute mapping에 `repository`, `repository_owner`, `ref`, `workflow_ref`, - `environment` claim을 포함한다. -3. attribute condition은 처음에 다음을 동시에 제한하도록 설계했다. 지금 condition은 `workflow_ref` - 허용 목록에 `release-distribution.yml`과 `release-play-internal.yml`도 들어 있다. 현재 값의 정본은 - Play release runbook 사전 준비 3번이다. +2. provider attribute mapping은 `google.subject`(`sub`), `repository`, `repository_owner`, `ref`, + `workflow_ref`를 싣는다. `environment` claim은 매핑하지 않는다. +3. attribute condition은 다음을 동시에 제한한다(2026-10-05 gcloud 조회). - repository: `Afternote/Afternote-FE` - - owner: `Afternote` - ref: `refs/heads/develop` 또는 `refs/heads/main` - - workflow: `.github/workflows/firebase-wif-canary.yml` - - environment: `release-distribution` + - workflow_ref: `firebase-wif-canary.yml`, `release-distribution.yml`, `release-play-internal.yml` + 중 하나로 시작 + + owner와 environment는 조건에 없다. owner는 repository 전체 이름 일치가 대신하고, environment + 승인은 provider가 아니라 각 워크플로의 GitHub Environment 보호 규칙이 건다. 워크플로별 자격 분리는 + 아래 5번의 서비스 계정 바인딩이 맡는다. 4. 보호된 Environment에 `GCP_WORKLOAD_IDENTITY_PROVIDER`와 `GCP_FIREBASE_SERVICE_ACCOUNT`를 masked secret으로 등록한다. 5. Firebase 서비스 계정 `afternote-ci`의 `roles/iam.workloadIdentityUser`는 저장소 전체가 아니라 From 818f8ccfded5e2037f0b0b390eb5323e3f464259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 14:15:14 +0000 Subject: [PATCH 098/116] =?UTF-8?q?ci(platform):=20CODEOWNERS=20=EB=A5=BC?= =?UTF-8?q?=20=EC=A7=80=EC=9B=8C=20.github=20=EB=B3=80=EA=B2=BD=20PR=20?= =?UTF-8?q?=EC=9D=98=20=EC=9E=90=EB=8F=99=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=84=20=EC=97=86=EC=95=A4=EB=8B=A4=20(#2283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰를 폐지(#2277)했으므로 /.github/ @1hyok 코드 소유자 지정이 만드는 자동 리뷰 요청도 없앤다. 그 줄을 단언하던 merge-order-guard 정책 테스트를 함께 지운다. --- .github/CODEOWNERS | 2 -- .github/scripts/merge-order-guard-policy.test.mjs | 5 ----- 2 files changed, 7 deletions(-) delete mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 0037c93efb..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -# Workflow definitions and their policy scripts are security-sensitive. -/.github/ @1hyok diff --git a/.github/scripts/merge-order-guard-policy.test.mjs b/.github/scripts/merge-order-guard-policy.test.mjs index 6aa57dae00..4b91502907 100644 --- a/.github/scripts/merge-order-guard-policy.test.mjs +++ b/.github/scripts/merge-order-guard-policy.test.mjs @@ -8,7 +8,6 @@ import test from "node:test"; const guard = await readFile(new URL("../workflows/merge-order-guard.yml", import.meta.url), "utf8"); const stackNotify = await readFile(new URL("../workflows/stack-integrity-notify.yml", import.meta.url), "utf8"); -const codeowners = await readFile(new URL("../CODEOWNERS", import.meta.url), "utf8"); function issueParser() { const match = /refs=\$\(\n\s+PR_JSON="\$pr_json" node <<'NODE'\n([\s\S]*?)\n[ \t]*NODE\n\s+\)/.exec(guard); @@ -257,10 +256,6 @@ test("close notifier executes only trusted default-branch policy", () => { assert.doesNotMatch(stackNotify, /actions\/checkout[\s\S]*github\.event\.pull_request\.head/); }); -test("declares the repository code owner for GitHub automation and policy changes", () => { - assert.match(codeowners, /^\/\.github\/ @1hyok$/m); -}); - test("live CLOSED warns upper PRs while OPEN or MERGED resolves the same bot comment", () => { assert.match(stackNotify, /issues: write/); // 코멘트 대상이 PR 이면 GitHub 은 pull-requests 쓰기를 요구한다. 이 칸이 read 로 From b643a3bed563fd5aea0d66359e3c5300bf9d5d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 13:48:21 +0900 Subject: [PATCH 099/116] Guard restored published edits after final editor MVI (#1732) --- .../editor/AfternoteEditorContract.kt | 1 + .../editor/AfternoteEditorViewModel.kt | 50 +++++- .../AfternoteEditorProcessDeathPrefillTest.kt | 148 ++++++++++++++---- 3 files changed, 161 insertions(+), 38 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt index 23a9b4d30d..0237e5c269 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorContract.kt @@ -231,6 +231,7 @@ internal sealed interface AfternoteEditorReducerEvent : ReducerEvent { data class PrefillLoaded( val prefill: EditorFormPrefill, val baseline: AfternoteEditorSnapshot, + val preserveRestoredForm: Boolean, ) : AfternoteEditorReducerEvent data object PrefillFailed : AfternoteEditorReducerEvent diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index 08ef703045..24e3e0bd1e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -62,6 +62,7 @@ import kotlinx.serialization.json.Json private const val EDITOR_FORM_SNAPSHOT_KEY = "editor_form_snapshot_v6" private const val INITIALIZED_ACTION_TEMPLATE_TYPE_KEY = "initialized_action_template_type" +private const val PREFILL_SEEDED_ITEM_ID_KEY = "editor_prefill_seeded_item_id" private const val TAG = "AfternoteEditorViewModel" @@ -193,6 +194,18 @@ internal class AfternoteEditorViewModel encodeDefaults = true } + private val restoredForm: RestoredForm = readFormSnapshotOrDefault() + + /** + * 상세 prefill을 적용했던 폼이 성공적으로 복원된 경우 사용자의 미저장 입력을 보존한다. + * 표식만 남거나 디코딩이 실패했을 때는 서버 prefill을 받아 빈 폼으로 저장하는 일을 막는다. + * 계정 정보와 남기실 말씀은 화면이 복원하므로 폼 값 비교로 적용 여부를 대신하지 않는다. + */ + private val restoredFromSeededSnapshot: Boolean = + route.itemId != null && + restoredForm.fromSnapshot && + savedStateHandle.get(PREFILL_SEEDED_ITEM_ID_KEY) == route.itemId + val isEditing: Boolean get() = route.itemId != null override fun onIntent(intent: AfternoteEditorIntent) { @@ -255,6 +268,7 @@ internal class AfternoteEditorViewModel } is AfternoteEditorIntent.ApplyPrefill -> { + readEditItemId()?.let { savedStateHandle[PREFILL_SEEDED_ITEM_ID_KEY] = it } dispatchForm(AfternoteEditorReducerEvent.PrefillApplied(intent.prefill)) } @@ -462,7 +476,9 @@ internal class AfternoteEditorViewModel is AfternoteEditorReducerEvent.PrefillLoaded -> { state.copy( originalType = event.prefill.type, - pendingPrefill = event.prefill, + pendingPrefill = if (event.preserveRestoredForm) null else event.prefill, + isPrefillLoading = + state.isPrefillLoading && !event.preserveRestoredForm, updateBaseline = event.baseline, ) } @@ -503,7 +519,7 @@ internal class AfternoteEditorViewModel init { dispatch( AfternoteEditorReducerEvent.Initialized( - readFormSnapshotOrDefault(), + restoredForm.form, route.initialType.takeIf { route.itemId != null }, readEditItemId() != null, ), @@ -513,16 +529,29 @@ internal class AfternoteEditorViewModel private fun readEditItemId(): Long? = route.itemId - private fun readFormSnapshotOrDefault(): EditorFormState { + /** 빈 기본 폼과 성공적으로 복원된 폼을 구분한다. */ + private fun readFormSnapshotOrDefault(): RestoredForm { val defaultForm = EditorFormState().withType(route.initialType) - val raw = savedStateHandle.get(EDITOR_FORM_SNAPSHOT_KEY) ?: return defaultForm + val raw = + savedStateHandle.get(EDITOR_FORM_SNAPSHOT_KEY) + ?: return RestoredForm(form = defaultForm, fromSnapshot = false) return runCatching { - formSnapshotJson - .decodeFromString(EditorFormSnapshot.serializer(), raw) - .toEditorFormState() - }.getOrElse { defaultForm } + RestoredForm( + form = + formSnapshotJson + .decodeFromString(EditorFormSnapshot.serializer(), raw) + .toEditorFormState(), + fromSnapshot = true, + ) + }.getOrElse { RestoredForm(form = defaultForm, fromSnapshot = false) } } + /** [readFormSnapshotOrDefault]의 폼과 실제 복원 성공 여부. */ + private data class RestoredForm( + val form: EditorFormState, + val fromSnapshot: Boolean, + ) + /** [EditorFormSnapshot] 직렬화. 실패 시 무시한다(용량 초과 등은 [EditorFormSnapshot] KDoc 참고). */ private fun persistFormSnapshot(form: EditorFormState) { runCatching { @@ -814,10 +843,15 @@ internal class AfternoteEditorViewModel // skeleton 종료는 UI 가 prefill 적용을 마친 뒤 [AfternoteEditorIntent.ConsumePrefill] 로 통보한다 // (uiState 갱신 시점에 prefill 도착했어도 UI 가 form·TextFieldState 에 반영하기 전이라 // 여기서 끄면 skeleton 사라짐 → 빈 폼 → prefill 깜빡임 발생). + // + // 복원된 편집이 있으면 프리필을 싣지 않는다 (#1732). 서버 조회는 유지한다. + // 기준 스냅샷([AfternoteEditorUiState.updateBaseline])이 없으면 저장이 막히고, 카테고리도 + // 서버가 아는 값이어야 한다. 복원된 입력에 대한 덮어쓰기만 생략한다. dispatch( AfternoteEditorReducerEvent.PrefillLoaded( prefill = prefill, baseline = AfternoteEditorFormMapper.buildUpdateBaseline(detail), + preserveRestoredForm = restoredFromSeededSnapshot, ), ) }.onFailure { e -> diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt index 844e349403..10ccd28724 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorProcessDeathPrefillTest.kt @@ -13,6 +13,7 @@ import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMediaForSaveUseCase import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.editor.model.EditorContentPrefill +import com.afternote.feature.afternote.presentation.editor.model.RegisterAfternotePayload import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,29 +25,16 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** - * **알려진 한계를 드러내는 가드** — 프로세스 사망 복원이 사용자의 미저장 편집을 서버 값으로 덮는다. - * - * 복원 경로는 두 갈래가 겹친다: `SavedStateHandle` 의 폼 스냅샷이 편집분을 되살리고, 곧이어 - * `init` 의 상세 재조회가 `pendingPrefill` 을 다시 발행해 UI 가 폼을 서버 값으로 덮어쓴다. - * 이 테스트는 그 두 번째 발행이 **폼이 더러운지 묻지 않고** 일어난다는 사실을 고정한다. - * - * 이 축은 #1617 의 부분 PATCH 가 만든 것이 아니고 고치지도 않는다 — 스냅샷 복원과 프리필 재적용이 - * 함께 있던 종전부터의 동작이다. 부분 PATCH 는 오히려 이 경로를 **덜 위험하게** 만든다: 덮인 폼은 - * 서버 값과 같아져 「바뀐 것 없음」으로 아무 슬롯도 실리지 않고, 프리필이 도착하기 전 저장을 - * 시도하면 기준이 없다는 이유로 [AfternoteEditorMissingBaselineTest] 의 가드가 막는다. - * - * 제대로 고치려면 「복원된 편집이 있으면 프리필을 덮어쓰지 않거나 사용자에게 고를 기회를 준다」는 - * 화면 흐름 결정이 필요하다 — 편집 유실 안내·충돌 해소 UI 는 #1617 의 해야 할 일 2번(서버 거절 시 - * 화면 동작)과 같은 결의 별도 축이라 그쪽에서 다룬다. - */ +/** 발행 수정 화면 복원 뒤 재조회가 미저장 입력을 보존하고 저장 기준은 갱신하는지 검증한다. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -64,7 +52,7 @@ class AfternoteEditorProcessDeathPrefillTest { } @Test - fun `복원된 편집이 있어도 상세 재조회 프리필이 다시 발행된다`() = + fun `복원된 편집이 있으면 상세 재조회가 프리필을 발행하지 않는다`() = runTest(dispatcher) { val repository = FakeAfternoteRepository.strict().apply { @@ -74,18 +62,111 @@ class AfternoteEditorProcessDeathPrefillTest { backgroundScope.launch { viewModel.uiState.collect {} } runCurrent() - // 복원 직후의 폼은 사용자가 고친 제목을 들고 있다. - assertEquals(EDITED_SERVICE, viewModel.uiState.value.form.selectedService) + assertEquals( + "복원된 폼은 사용자가 고친 제목을 그대로 들고 있다", + EDITED_SERVICE, + viewModel.uiState.value.form.selectedService, + ) + assertNull( + "재조회가 프리필을 발행하면 화면이 그것을 실어 사용자의 편집이 사라진다", + viewModel.uiState.value.pendingPrefill, + ) + } + + @Test + fun `프리필을 막아도 복원된 편집 그대로 저장이 나간다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + onUpdate = { id, _ -> Result.success(id) } + } + val viewModel = viewModel(repository) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + assertFalse( + "프리필을 싣지 않았으니 skeleton 도 여기서 걷혀야 한다", + viewModel.uiState.value.isPrefillLoading, + ) + + // 화면이 복원된 폼 값을 그대로 담아 보내는 저장이다. + viewModel.onIntent( + AfternoteEditorIntent.Save( + payload = + RegisterAfternotePayload( + serviceName = EDITED_SERVICE, + date = "2026-08-30", + processingMethods = listOf(SERVER_PROCESSING_METHOD), + ), + selectedReceiverIds = emptyList(), + memorialMedia = SaveAfternoteMemorialMedia(), + ), + ) + runCurrent() + + val updated = repository.updateCalls.single().second + assertEquals("사용자가 고친 제목이 그대로 나간다", EDITED_SERVICE, updated.title) + assertNull( + "복원된 폼이 서버 값을 그대로 들고 있으므로 안 건드린 필드는 실리지 않는다 (#1617)", + updated.processingMethods, + ) + } + + @Test + fun `프리필이 실린 적 없는 복원은 여전히 프리필을 받는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository, prefillSeeded = false) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + val prefill = viewModel.uiState.value.pendingPrefill + assertNotNull("서버 값을 본 적 없는 폼은 프리필로 채워야 한다", prefill) + assertEquals( + SERVER_SERVICE, + (prefill?.content as EditorContentPrefill.Gallery).serviceName, + ) + } + + @Test + fun `스냅샷이 깨져 빈 폼으로 떨어진 복원은 프리필을 받는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository, snapshot = "{ 이건 EditorFormSnapshot 이 아니다 }") + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() val prefill = viewModel.uiState.value.pendingPrefill - assertNotNull("상세 재조회는 폼이 더러운지 묻지 않고 프리필을 발행한다", prefill) + assertNotNull("빈 폼으로 떨어졌으면 프리필을 막으면 안 된다", prefill) assertEquals( - "그 프리필은 서버 값이라, UI 가 적용하면 사용자의 편집이 사라진다", SERVER_SERVICE, (prefill?.content as EditorContentPrefill.Gallery).serviceName, ) } + @Test + fun `표식만 남고 폼 스냅샷이 없으면 프리필을 받는다`() = + runTest(dispatcher) { + val repository = + FakeAfternoteRepository.strict().apply { + onGetDetail = { Result.success(serverDetail()) } + } + val viewModel = viewModel(repository, snapshot = null) + backgroundScope.launch { viewModel.uiState.collect {} } + runCurrent() + + val prefill = viewModel.uiState.value.pendingPrefill + assertNotNull("실제 폼 복원이 없으면 서버 값을 받아야 한다", prefill) + assertEquals(SERVER_SERVICE, (prefill?.content as EditorContentPrefill.Gallery).serviceName) + } + private fun serverDetail() = Detail( id = EDIT_ID, @@ -93,26 +174,32 @@ class AfternoteEditorProcessDeathPrefillTest { timestamps = DetailTimestamps(updatedAt = "2026-08-30"), receivers = emptyList(), leaveMessageBlocks = emptyList(), - content = DetailContent.Gallery(processingMethods = listOf("파일 전달")), + content = DetailContent.Gallery(processingMethods = listOf(SERVER_PROCESSING_METHOD)), ) - /** 프로세스 사망 전에 저장돼 있던 폼 스냅샷 — 사용자가 제목을 고쳐 둔 상태다. */ + /** 서버 prefill 적용 뒤 제목만 고친 폼. */ private fun restoredSnapshot(): String = """ {"type":"GALLERY_AND_FILES","selectedService":"$EDITED_SERVICE","receivers":[], - "processingMethods":[],"memorialPlaylistSongs":[]} + "processingMethods":[{"localId":0,"text":"$SERVER_PROCESSING_METHOD"}],"memorialPlaylistSongs":[]} """.trimIndent().replace("\n", "") - private fun viewModel(afternoteRepository: FakeAfternoteRepository): AfternoteEditorViewModel = + private fun viewModel( + afternoteRepository: FakeAfternoteRepository, + prefillSeeded: Boolean = true, + snapshot: String? = restoredSnapshot(), + ): AfternoteEditorViewModel = AfternoteEditorViewModel( route = AfternoteRoute.EditorFlowRoute(itemId = EDIT_ID, initialType = AfternoteType.GALLERY_AND_FILES), savedStateHandle = SavedStateHandle( - mapOf( - "initialType" to AfternoteType.GALLERY_AND_FILES, - "itemId" to EDIT_ID, - "editor_form_snapshot_v6" to restoredSnapshot(), - ), + buildMap { + put("initialType", AfternoteType.GALLERY_AND_FILES) + put("itemId", EDIT_ID) + if (snapshot != null) put("editor_form_snapshot_v6", snapshot) + // 화면이 프리필을 폼에 실을 때 ViewModel 이 같은 번들에 남기는 표식. + if (prefillSeeded) put("editor_prefill_seeded_item_id", EDIT_ID) + }, ), userReceiverRepository = FakeUserReceiverRepository.strict(), afternoteRepository = afternoteRepository, @@ -137,5 +224,6 @@ class AfternoteEditorProcessDeathPrefillTest { const val EDIT_ID = 73L const val EDITED_SERVICE = "사용자가 고친 제목" const val SERVER_SERVICE = "구글 포토" + const val SERVER_PROCESSING_METHOD = "파일 전달" } } From 1360fda38d7879d2b1222c88a83f4c2c8101084c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Thu, 1 Oct 2026 19:03:29 +0900 Subject: [PATCH 100/116] fix(core): restore legacy biometric authentication (#2200) --- app/src/main/res/values-v29/themes.xml | 5 + app/src/main/res/values/themes.xml | 6 +- .../afternote_fe/BiometricDialogThemeTest.kt | 100 +++++++ .../core/common/biometric/BiometricAuth.kt | 22 +- .../common/biometric/BiometricCryptoGate.kt | 21 +- .../passkey/PassKeyMakingApi29Test.kt | 268 +++++++++++++++--- gradle/libs.versions.toml | 3 +- 7 files changed, 374 insertions(+), 51 deletions(-) create mode 100644 app/src/main/res/values-v29/themes.xml create mode 100644 app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt diff --git a/app/src/main/res/values-v29/themes.xml b/app/src/main/res/values-v29/themes.xml new file mode 100644 index 0000000000..4232750e1d --- /dev/null +++ b/app/src/main/res/values-v29/themes.xml @@ -0,0 +1,5 @@ + + + + - \ No newline at end of file + diff --git a/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt b/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt new file mode 100644 index 0000000000..8744dce378 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt @@ -0,0 +1,100 @@ +package com.afternote.afternote_fe + +import android.app.Activity +import android.app.Application +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Looper +import android.util.TypedValue +import android.widget.Button +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** 실제 앱 테마로 AndroidX 지문 대화상자와 같은 AppCompat dialog를 생성한다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 27, 28], application = Application::class) +class BiometricDialogThemeTest { + @Test + fun `API26부터 28 앱 테마는 AppCompat 인증 대화상자를 띄우고 닫을 수 있다`() { + val controller = Robolectric.buildActivity(Activity::class.java) + controller.get().setTheme(R.style.Theme_Afternotefe_Main) + controller.setup() + val activity = controller.get() + var canceled = false + val dialog = createAppCompatDialog(activity) { canceled = true } + + try { + // 플랫폼 Material 테마만 있으면 show()에서 AppCompat 테마 요구 예외가 발생한다. + dialog.show() + assertTrue(dialog.isShowing) + dialog.findViewById