From dbe8e398a51ee51d65217c04a8d2631a57fa55e8 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 15:06:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(mindrecord):=20=EB=8D=B0=EC=9D=BC?= =?UTF-8?q?=EB=A6=AC=EC=A7=88=EB=AC=B8=20=EC=83=9D=EC=84=B1=C2=B7=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=91=EB=8B=B5=EC=9D=98=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createDailyQuestion`·`updateDailyQuestion` 의 반환 타입이 `BaseResponse` 이라 서버가 내려주는 `userDailyQuestionId` 를 받아놓고 버렸다. 저장 직후 그 레코드를 가리키려면 목록을 다시 조회해 추측으로 골라야 한다. 응답 DTO(`DailyQuestionAnswerResponseDto`)를 만들어 API·domain·data 계약을 모두 `Result` 으로 올린다. 임시저장 성공 시 그 값을 `draftId` 로 들어, 같은 화면에서 이어 저장할 때 목록을 뒤지지 않고 PATCH 한다. `userDailyQuestionId`("내 답변")와 요청의 `questionId`("질문")는 의미가 다르다. 관찰된 값이 같더라도 섞어 쓰지 않도록 DTO KDoc 에 적어 둔다. 식별자에는 기본값을 두지 않는다 — 없으면 저장 직후 흐름이 성립하지 않으므로 0 으로 메우지 않고 파싱에서 막는다. Closes #573 Co-Authored-By: Claude Opus 5 (1M context) --- .../data/api/DailyQuestionApiService.kt | 5 +- .../mindrecord/data/dto/DailyQuestionDto.kt | 16 ++++ .../DailyQuestionRepositoryImpl.kt | 12 ++- ...DailyQuestionAnswerResponseContractTest.kt | 83 +++++++++++++++++++ .../repository/DailyQuestionRepository.kt | 11 ++- .../viewmodel/DailyQuestionWriteViewModel.kt | 13 ++- 6 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt index ad9b549a48..a2ac76ea39 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/api/DailyQuestionApiService.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.data.api import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.mindrecord.data.dto.DailyQuestionAnswerResponseDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionCreateRequestDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionListItemDto import com.afternote.feature.mindrecord.data.dto.DailyQuestionUpdateRequestDto @@ -27,13 +28,13 @@ interface DailyQuestionApiService { @POST("daily-questions") suspend fun createDailyQuestion( @Body request: DailyQuestionCreateRequestDto, - ): BaseResponse + ): BaseResponse @PATCH("daily-questions/{userDailyQuestionId}") suspend fun updateDailyQuestion( @Path("userDailyQuestionId") userDailyQuestionId: Long, @Body request: DailyQuestionUpdateRequestDto, - ): BaseResponse + ): BaseResponse @DELETE("daily-questions/{userDailyQuestionId}") suspend fun deleteDailyQuestion( diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt index 6b48f61d73..20f48b1819 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DailyQuestionDto.kt @@ -63,3 +63,19 @@ data class TodayDailyQuestionDto( @JsonNames("draft") val isDraft: Boolean = false, ) + +/** + * 생성·수정 응답 (`DailyQuestionAnswerResponse` 실측, 2026-08-23). + * + * 종전에는 `BaseResponse` 으로 받아 방금 만든 답변의 식별자를 버렸다. 그러면 저장 + * 직후 그 레코드를 가리키려고 목록을 다시 조회해 추측으로 찾아야 한다 (#573). + * + * `userDailyQuestionId`("내 답변")와 요청의 `questionId`("질문")는 의미가 다르다. + * 관찰된 값이 같더라도 섞어 쓰면 안 된다. + */ +@Serializable +data class DailyQuestionAnswerResponseDto( + @SerialName("userDailyQuestionId") val userDailyQuestionId: Long, + @SerialName("content") val content: String = "", + @SerialName("isDraft") val isDraft: Boolean = false, +) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt index ae1631c2b5..ea665f4378 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/repositoryimpl/DailyQuestionRepositoryImpl.kt @@ -33,19 +33,23 @@ class DailyQuestionRepositoryImpl api.getTodayDailyQuestion().requireData().toDomain() } - override suspend fun create(payload: DailyQuestionCreatePayload): Result = + override suspend fun create(payload: DailyQuestionCreatePayload): Result = runCatching { - api.createDailyQuestion(payload.toRequest()).requireStatus() + api + .createDailyQuestion(payload.toRequest()) + .requireData() + .userDailyQuestionId } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = + ): Result = runCatching { api .updateDailyQuestion(userDailyQuestionId = id, request = payload.toRequest()) - .requireStatus() + .requireData() + .userDailyQuestionId } override suspend fun delete(id: Long): Result = diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt new file mode 100644 index 0000000000..7671fb49c5 --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionAnswerResponseContractTest.kt @@ -0,0 +1,83 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 생성·수정 응답의 `userDailyQuestionId` 계약을 고정한다 (#573). + * + * 종전에는 `BaseResponse` 으로 받아 이 값을 버렸다. 그러면 저장 직후 그 레코드를 + * 가리키려고 목록을 다시 조회해 추측으로 골라야 한다. + * + * 아래 본문은 2026-08-23 `afternote.kro.kr` 실측 응답 그대로다. + */ +class DailyQuestionAnswerResponseContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `생성 응답에서 내 답변 식별자를 읽는다`() { + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "userDailyQuestionId": 19, "content": "응답 계약 실측", + "isDraft": true, "receivers": [] } + } + """.trimIndent() + + val decoded = + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + + assertEquals(19L, decoded.data!!.userDailyQuestionId) + assertEquals("응답 계약 실측", decoded.data!!.content) + assertEquals(true, decoded.data!!.isDraft) + } + + @Test + fun `수정 응답도 같은 스키마다`() { + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "userDailyQuestionId": 19, "content": "응답 계약 실측 수정", + "isDraft": true, "receivers": [] } + } + """.trimIndent() + + val decoded = + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + + assertEquals(19L, decoded.data!!.userDailyQuestionId) + } + + @Test + fun `식별자가 없으면 실패로 드러난다`() { + // 기본값을 두지 않는다 — 이 값이 없으면 저장 직후 흐름이 성립하지 않으므로 + // 조용히 0 으로 메우지 않고 파싱에서 막는다. + val body = + """ + { + "status": 200, "code": 200, "message": "성공", + "data": { "content": "식별자 없음", "isDraft": true } + } + """.trimIndent() + + val failed = + runCatching { + json.decodeFromString( + BaseResponse.serializer(DailyQuestionAnswerResponseDto.serializer()), + body, + ) + }.isFailure + + assertEquals(true, failed) + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt index 51671c88d5..b001a7a158 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/repository/DailyQuestionRepository.kt @@ -19,12 +19,19 @@ interface DailyQuestionRepository { suspend fun getToday(): Result - suspend fun create(payload: DailyQuestionCreatePayload): Result + /** + * 답변 생성. + * + * 서버가 돌려주는 `userDailyQuestionId`("내 답변" 식별자)를 그대로 올린다 — 저장 직후 + * 그 레코드를 가리켜야 하는 흐름(이어쓰기·수정·삭제)이 목록을 다시 뒤지지 않게 한다 (#573). + */ + suspend fun create(payload: DailyQuestionCreatePayload): Result + /** 답변 수정. 생성과 같은 스키마로 응답하므로 식별자를 그대로 올린다 (#573). */ suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result + ): Result suspend fun delete(id: Long): Result } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index c9605a386f..e073263f44 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -162,8 +162,17 @@ class DailyQuestionWriteViewModel ) } result - .onSuccess { - _uiState.update { it.copy(submitState = SubmitState.Succeeded) } + .onSuccess { savedId -> + _uiState.update { + it.copy( + submitState = SubmitState.Succeeded, + // 서버가 돌려준 "내 답변" 식별자를 그대로 든다 (#573). + // 임시저장 뒤 이어서 저장하면 목록을 다시 뒤지지 않고 이 값으로 PATCH 한다 — + // 종전에는 응답을 버려 `resumeDraft()` 가 목록을 재조회해 첫 draft 를 + // 추측으로 골랐다. + draftId = if (isDraft) savedId else null, + ) + } }.onFailure { e -> _uiState.update { it.copy( From ad74e7adcfcf934d9e0cb16f606d201b1d51a5cc Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 15:07:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(mindrecord):=20fake=20repository=20?= =?UTF-8?q?=EB=A5=BC=20=EC=83=88=20Result=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodel/DailyQuestionWriteViewModelTest.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index e266863d89..98e68d7bdf 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -176,15 +176,16 @@ private class FakeDailyQuestionRepository( override suspend fun getToday(): Result = onGetToday() - override suspend fun create(payload: DailyQuestionCreatePayload): Result { + override suspend fun create(payload: DailyQuestionCreatePayload): Result { createCallCount += 1 - return Result.success(Unit) + // 서버가 돌려주는 "내 답변" 식별자 (#573). + return Result.success(CREATED_ANSWER_ID) } override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") + ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") override suspend fun delete(id: Long): Result = error("delete 는 이 시나리오에서 호출되면 안 됨") } @@ -195,3 +196,5 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { directory: String, ): Result = error("upload 는 이 시나리오에서 호출되면 안 됨") } + +private const val CREATED_ANSWER_ID = 19L From 9f88772f2a4825e27233ccbdcc26ec0c51b2ca31 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 19:53:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(app):=20HomeTabViewModelTest=20fake=20?= =?UTF-8?q?=EB=A5=BC=20=EC=83=88=20Result=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #573 이 DailyQuestionRepository.create/update 를 Result 으로 올리면서 app 모듈의 fake 도 함께 따라가야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt index e9e7c9de71..6980f9187c 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt @@ -461,12 +461,12 @@ private class FakeDailyQuestionRepository : DailyQuestionRepository { draftOnly: Boolean?, ): Result> = unexpected("DailyQuestionRepository.getList") - override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") + override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = unexpected("DailyQuestionRepository.update") + ): Result = unexpected("DailyQuestionRepository.update") override suspend fun delete(id: Long): Result = unexpected("DailyQuestionRepository.delete") }