From b765ddb642b55e341a8d213f5787cf05019200c5 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 20:25:34 +0900 Subject: [PATCH] =?UTF-8?q?refactor(mindrecord):=20DTO=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EA=B3=84=EC=95=BD=EC=9D=B4=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EB=93=9C=EB=9F=AC?= =?UTF-8?q?=EB=82=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release API 가 항상 채우는 응답 키까지 `false`·`0`·`""`·`emptyList()` 로 보정하고 있어서, 응답 키 누락과 계약 변경이 파싱 실패가 아니라 정상적인 빈 값으로 바뀌었다. `isDraft=false` 는 임시저장 노출로, 수치 `0` 은 잘못된 주간 리포트로, 빈 날짜는 캘린더에서 기록이 사라지는 것으로 이어진다. DailyQuestionDto / DiaryDto / MindRecordDto / WeeklyReportDto 네 파일의 기본 파라미터를 필드별 계약과 생성 호출부 기준으로 제거하고, DTO 밖까지 번진 불필요한 nullable(`Diary.date`, `Diary.todayMood`, `DiaryCreatePayload.receiverIds`)을 non-null 로 좁혔다. 응답 계약에 없는 `imageUrl` 만 기본값을 유지한다. 기본값을 걷어내면 `coerceInputValues` 가 동작하지 않아 "모르는 enum 값" 까지 함께 실패하게 되는데, 키 누락(계약 변경)과 값 확장(표기 변경)은 성격이 다르다. nullable 기분 필드에는 `NullableTodayMoodSerializer` 를 달아 키의 존재는 계약으로 두고 값만 관대하게 접는다 — 이모지 한 칸 때문에 그 주/그 달 목록 전체를 날리지 않는다. Closes #789 --- .../mindrecord/data/dto/DailyQuestionDto.kt | 36 ++-- .../feature/mindrecord/data/dto/DiaryDto.kt | 48 +++--- .../mindrecord/data/dto/MindRecordDto.kt | 20 ++- .../data/dto/NullableTodayMoodSerializer.kt | 42 +++++ .../mindrecord/data/dto/WeeklyReportDto.kt | 39 +++-- .../mindrecord/data/mapper/DiaryMapper.kt | 2 +- .../data/dto/DiaryListContractTest.kt | 101 +++++++++--- .../data/dto/MindRecordDtoContractTest.kt | 154 ++++++++++++++++++ .../dto/TodayDailyQuestionContractTest.kt | 26 +++ .../data/dto/WeeklyReportContractTest.kt | 65 ++++++-- .../mindrecord/domain/model/DiaryModels.kt | 12 +- .../presentation/mapper/MindRecordUiMapper.kt | 2 +- .../viewmodel/DiaryWriteViewModel.kt | 2 +- .../mapper/MindRecordUiMapperTest.kt | 11 +- 14 files changed, 458 insertions(+), 102 deletions(-) create mode 100644 feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt create mode 100644 feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt 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..846982506d 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 @@ -12,16 +12,22 @@ data class DailyQuestionCreateRequestDto( @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, @SerialName("questionId") val questionId: Long, - @SerialName("imageUrl") val imageUrl: String? = null, + // 응답/요청 계약에 없는 필드라 타입은 nullable 로 두되 기본값은 두지 않는다. + // 매퍼가 항상 명시적으로 넘기므로 생략이 조용히 통과할 자리가 없다 (#789). + @SerialName("imageUrl") val imageUrl: String?, ) +/** + * `PATCH /daily-questions/{id}` 요청. 선택 필드라 nullable 은 유지하되 기본값은 두지 않는다 — + * 모든 생성 인자를 명시해야 "보내지 않음" 과 "명시적 null" 이 호출부 코드에서 구분된다 (#789). + */ @Serializable data class DailyQuestionUpdateRequestDto( - @SerialName("content") val content: String? = null, - @SerialName("isDraft") val isDraft: Boolean? = null, - @SerialName("date") val date: String? = null, - @SerialName("questionId") val questionId: Long? = null, - @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("content") val content: String?, + @SerialName("isDraft") val isDraft: Boolean?, + @SerialName("date") val date: String?, + @SerialName("questionId") val questionId: Long?, + @SerialName("imageUrl") val imageUrl: String?, ) @Serializable @@ -35,12 +41,17 @@ data class DailyQuestionListItemDto( @SerialName("title") val title: String, @SerialName("content") val content: String, @SerialName("createdAt") val createdAt: String, + // Swagger `DailyQuestionListResponse` 응답 계약에 없는 필드 — 서버가 주기 시작하면 쓰이고, + // 아니면 계속 null. 계약에 없으므로 여기서만 기본값을 유지한다 (#789). @SerialName("imageUrl") val imageUrl: String? = null, // Swagger `DailyQuestionListResponse` 및 실서버 응답 모두 와이어 키는 `isDraft`. // 과거 QA logcat 에서 `draft` 로 관측된 적이 있어 대체 키도 함께 허용한다. + // + // 기본값 `false` 는 두지 않는다 — 서버가 항상 보내는 키라, 빠졌을 때 "임시저장 아님" 으로 + // 접히면 임시저장 글이 목록에 그대로 노출된다. 계약 누락은 파싱 실패로 드러나야 한다 (#789). @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, ) @Serializable @@ -54,12 +65,15 @@ data class TodayDailyQuestionDto( @SerialName("day") val day: Int, @SerialName("content") val content: String, // Swagger `DailyQuestionTodayResponse` 및 실서버 응답 모두 `isAnswered`/`isDraft`. - // 종전 `answered`/`draft` 로 잡혀 있어 필수 필드 누락(MissingFieldException)으로 - // getToday() 가 항상 실패했다 (#548). 과도기 대비로 구 키도 함께 받고 기본값을 둔다. + // 종전 DTO 가 `answered`/`draft` 로 잡혀 있어 필수 필드 누락(MissingFieldException)으로 + // getToday() 가 항상 실패했다 (#548). 과도기 대비로 구 키는 계속 함께 받는다. + // + // 기본값은 두지 않는다 — 두 키 다 서버가 항상 보내므로, 기본값이 있으면 이번 같은 키 + // 불일치가 다시 나도 "미답변·임시저장 아님" 인 정상 응답으로 조용히 통과한다 (#789). @SerialName("isAnswered") @JsonNames("answered") - val isAnswered: Boolean = false, + val isAnswered: Boolean, @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt index c996fab631..5826c340d8 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt @@ -25,8 +25,11 @@ data class DiaryCreateRequestDto( @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, @SerialName("todayMood") val todayMood: TodayMoodDto, - @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("receiverIds") val receiverIds: List? = null, + // 응답 계약에 없는 필드 — 타입만 nullable 로 두고 기본값은 두지 않는다 (#789). + @SerialName("imageUrl") val imageUrl: String?, + // 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상 + // 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789). + @SerialName("receiverIds") val receiverIds: List, ) @Serializable @@ -36,23 +39,20 @@ data class DiaryUpdateRequestDto( @SerialName("isDraft") val isDraft: Boolean, @SerialName("todayMood") val todayMood: TodayMoodDto, @SerialName("date") val date: String, - @SerialName("imageUrl") val imageUrl: String? = null, + @SerialName("imageUrl") val imageUrl: String?, ) /** - * `/diary` 목록 항목 (Swagger `DiaryResponse` 실측, 2026-08-03). + * `/diary` 목록 항목 (Swagger `DiaryResponse`, 실서버 응답 실측 2026-08-23). * - * 식별자를 뺀 나머지에 기본값을 두는 이유는 **실패의 폭**이다. 이 DTO 는 목록 원소라, - * 필드 하나가 비어 `MissingFieldException` 이 나면 그 항목이 아니라 **그 달 목록 전체**가 - * 날아간다. 반면 값이 없을 때 잃는 것은 카드 한 장의 표시 요소뿐이다. - * - * `todayMood` 를 nullable 로 둔 것도 같은 맥락이다. non-null 이면 클라가 모르는 기분 값 - * 하나에 목록이 통째로 사라진다 (`coerceInputValues` 는 기본값이 있어야 동작한다). - * 실제로 `emotion` 은 한글로 내려오는 중이다 (#591). + * 식별자를 뺀 나머지에도 기본값을 두지 않는다. 이 필드들은 서버가 항상 채워 보내는 값이라, + * 기본값이 있으면 응답 키 누락과 계약 변경이 파싱 실패가 아니라 **정상적인 빈 값**으로 바뀐다. + * 특히 `isDraft` 가 `false` 로 접히면 임시저장 일기가 목록에 노출되고, `date` 가 빈 문자열이 + * 되면 캘린더에서 기록이 통째로 사라진다 (#789). * * (문서의 `required` 목록은 근거로 쓰지 않는다 — springdoc 은 `@Schema(requiredMode)` 가 * 없으면 non-null 프로퍼티도 required 에 넣지 않아, 비어 있다는 사실이 서버가 그 필드를 - * 생략한다는 신호가 아니다. 응답 필드의 nullable 판단 기준 자체는 #676 소관.) + * 생략한다는 신호가 아니다. 판단은 실제 응답과 저장 컬럼 필수 여부를 기준으로 한다.) * * `id` 는 노션 명세("Diary 조회") 예시의 키다. Swagger 에는 없지만 두 문서가 갈려 있어 * 대체 키로 함께 받는다 — 실제 응답에 `id` 가 없어 충돌하지 않는다. @@ -62,8 +62,8 @@ data class DiaryListItemDto( @SerialName("diaryId") @JsonNames("id") val diaryId: Long, - @SerialName("title") val title: String = "", - @SerialName("content") val content: String = "", + @SerialName("title") val title: String, + @SerialName("content") val content: String, /** * 사용자가 고른 **일기의 날짜** (`format: date`, 예 `"2026-03-21"`). * @@ -71,21 +71,27 @@ data class DiaryListItemDto( * `createdAt` 은 레코드가 만들어진 시각이다. 캘린더에 찍어야 하는 쪽은 이 값이다. * 둘을 `@JsonNames` 로 한 프로퍼티에 묶으면 서버의 키 순서에 따라 값이 뒤바뀐다. */ - @SerialName("date") val date: String? = null, - @SerialName("createdAt") val createdAt: String = "", + @SerialName("date") val date: String, + @SerialName("createdAt") val createdAt: String, // Swagger `DiaryResponse` 에 없는 필드 — 서버가 주기 시작하면 쓰이고, 아니면 계속 null. @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("todayMood") val todayMood: TodayMoodDto? = null, + // 저장 컬럼이 필수라 응답도 항상 채워진다. AI 가 매기는 `emotion` 과 달리 사용자가 직접 + // 고른 값이고, 한글 값이 관측된 쪽도 `emotion` 이지 이 필드가 아니다 (#591, #789). + @SerialName("todayMood") val todayMood: TodayMoodDto, @SerialName("isDraft") @JsonNames("draft") - val isDraft: Boolean = false, + val isDraft: Boolean, ) // `/diary` 응답의 `data` 는 객체 — `diaries` 외에 조회 대상 달의 비-임시 다이어리 수 // (`monthDiaryCount`)와 최근 7일 최빈 기분(`weeklyDominantMood`)이 함께 내려옴. @Serializable data class DiaryListDto( - @SerialName("diaries") val diaries: List = emptyList(), - @SerialName("monthDiaryCount") val monthDiaryCount: Int = 0, - @SerialName("weeklyDominantMood") val weeklyDominantMood: TodayMoodDto? = null, + @SerialName("diaries") val diaries: List, + @SerialName("monthDiaryCount") val monthDiaryCount: Int, + // 그 주에 기록이 없으면 서버가 `null` 을 **명시적으로** 보낸다. 값이 없다는 뜻이 실제로 + // 있으므로 nullable 은 유지하되, 키 자체는 계약이라 기본값은 두지 않는다 (#789). + @SerialName("weeklyDominantMood") + @Serializable(with = NullableTodayMoodSerializer::class) + val weeklyDominantMood: TodayMoodDto?, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt index 01def56649..073ded6d43 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/MindRecordDto.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable /** `GET /receiver-auth/daily-question` 응답 (`data`). */ @Serializable data class ReceiverDailyQuestionListDto( - @SerialName("dailyQuestions") val dailyQuestions: List = emptyList(), + @SerialName("dailyQuestions") val dailyQuestions: List, ) @Serializable @@ -17,26 +17,32 @@ data class ReceiverDailyQuestionItemDto( @SerialName("content") val content: String, // "yyyy.MM.dd 요일" 형식. @SerialName("createdAt") val createdAt: String, + // Swagger `DailyQuestionListResponse` 계약에 없는 필드 — 기본값을 유지한다 (#789). @SerialName("imageUrl") val imageUrl: String? = null, ) /** `GET /receiver-auth/diary` 응답 (`data`). */ @Serializable data class ReceiverDiaryListDto( - @SerialName("diaries") val diaries: List = emptyList(), + @SerialName("diaries") val diaries: List, ) +/** + * 수신자용 일기 항목. 발신자 목록과 **같은 `DiaryResponse` 계약**을 쓰므로 + * nullability 와 기본값도 [DiaryListItemDto] 와 동일하게 맞춘다 (#789). + */ @Serializable data class ReceiverDiaryItemDto( @SerialName("diaryId") val diaryId: Long, @SerialName("title") val title: String, @SerialName("content") val content: String, - @SerialName("isDraft") val isDraft: Boolean = false, + @SerialName("isDraft") val isDraft: Boolean, + // 응답 계약에 없는 필드 — 기본값을 유지한다. @SerialName("imageUrl") val imageUrl: String? = null, - @SerialName("todayMood") val todayMood: TodayMoodDto? = null, + @SerialName("todayMood") val todayMood: TodayMoodDto, // 작성일 (yyyy-MM-dd). - @SerialName("date") val date: String = "", + @SerialName("date") val date: String, // "yyyy.MM.dd 요일" 형식. - @SerialName("createdAt") val createdAt: String = "", - @SerialName("updatedAt") val updatedAt: String = "", + @SerialName("createdAt") val createdAt: String, + @SerialName("updatedAt") val updatedAt: String, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt new file mode 100644 index 0000000000..9960a734e1 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/NullableTodayMoodSerializer.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.mindrecord.data.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * 값 자체가 조건부인 **nullable 기분 필드** 전용 직렬화기. + * + * `coerceInputValues` 는 생성자 기본값이 있어야 동작한다. #789 로 기본값을 걷어내면서 + * 이 필드들도 함께 엄격해졌는데, 그 결과 "키 누락" 뿐 아니라 "클라가 모르는 **값**" 까지 + * 파싱 실패가 됐다. 두 상황은 성격이 다르다. + * + * - 키 누락 → 계약이 바뀐 것. 실패해야 드러난다. + * - 모르는 값 → 서버가 기분 종류를 늘렸거나 표기가 바뀐 것. 그 주/그 달 목록 전체를 + * 날릴 일이 아니라 이모지 한 칸만 비우면 된다 (#591 에서 감정 값이 한글로 관측된 전례). + * + * 그래서 키의 존재는 계약으로 두고(`= null` 기본값 없음), 값만 관대하게 접는다. + */ +object NullableTodayMoodSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.afternote.mindrecord.NullableTodayMood", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): TodayMoodDto? { + if (!decoder.decodeNotNullMark()) { + return decoder.decodeNull() + } + val raw = decoder.decodeString() + return TodayMoodDto.entries.firstOrNull { it.name == raw } + } + + override fun serialize( + encoder: Encoder, + value: TodayMoodDto?, + ) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value.name) + } +} diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt index 589cc4b443..6e9cb36337 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt @@ -3,15 +3,19 @@ package com.afternote.feature.mindrecord.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * `GET /mind-record` 응답 (`data`). 카운트·요약·목록 어느 것도 기본값을 두지 않는다 — + * 수치가 `0` 으로, 목록이 빈 목록으로 접히면 잘못된 주간 리포트가 정상 화면으로 보인다 (#789). + */ @Serializable data class WeeklyReportDto( - @SerialName("dailyQuestionAmount") val dailyQuestionAmount: Int = 0, - @SerialName("diaryAmount") val diaryAmount: Int = 0, - @SerialName("summaryText") val summaryText: String = "", - @SerialName("week") val week: List = emptyList(), + @SerialName("dailyQuestionAmount") val dailyQuestionAmount: Int, + @SerialName("diaryAmount") val diaryAmount: Int, + @SerialName("summaryText") val summaryText: String, + @SerialName("week") val week: List, // 서버 JSON 키가 kebab-case (`daily-question`). - @SerialName("daily-question") val dailyQuestions: List = emptyList(), - @SerialName("emotions") val emotions: List = emptyList(), + @SerialName("daily-question") val dailyQuestions: List, + @SerialName("emotions") val emotions: List, ) /** @@ -25,28 +29,31 @@ data class WeeklyReportDto( * 종전 DTO 는 존재하지 않는 `isDiary` 키를 읽어 항상 기본값 `false` 가 됐고, 그래서 모든 * 일기가 non-diary 로 접혀 캘린더 점과 기록일수에서 빠졌다. * - * 세 필드는 명세가 required 로 선언한 값이라 기본값을 두지 않는다 — 빠지면 실패해야 - * 계약 누락이 드러난다. 다만 [type] 은 `enum` 대신 `String` 으로 받는다. 서버가 종류를 - * 하나 더 추가해도(현재 3종) 그 주 전체가 죽지 않고 매퍼에서 "일기 아님" 으로 접히면 - * 되기 때문이다 — 값 집합은 닫혀 있지 않지만 키의 존재는 계약이다. + * 네 필드 모두 기본값을 두지 않는다 — 빠지면 실패해야 계약 누락이 드러난다. [emotion] 은 + * 값 자체가 조건부라 nullable 은 유지하지만, 서버가 키는 명시적으로 보내므로 기본값은 + * 없다 (#789). 다만 [type] 은 `enum` 대신 `String` 으로 받는다. 서버가 종류를 하나 더 + * 추가해도(현재 3종) 그 주 전체가 죽지 않고 매퍼에서 "일기 아님" 으로 접히면 되기 + * 때문이다 — 값 집합은 닫혀 있지 않지만 키의 존재는 계약이다. */ @Serializable data class WeeklyReportDayDto( @SerialName("diaryId") val diaryId: Long, @SerialName("day") val day: Int, @SerialName("type") val type: String, - @SerialName("emotion") val emotion: TodayMoodDto? = null, + @SerialName("emotion") + @Serializable(with = NullableTodayMoodSerializer::class) + val emotion: TodayMoodDto?, ) @Serializable data class WeeklyReportDailyQuestionDto( - @SerialName("title") val title: String = "", - @SerialName("content") val content: String = "", - @SerialName("date") val date: String = "", + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("date") val date: String, ) @Serializable data class WeeklyReportEmotionDto( - @SerialName("keyword") val keyword: String = "", - @SerialName("percentage") val percentage: Int = 0, + @SerialName("keyword") val keyword: String, + @SerialName("percentage") val percentage: Int, ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt index 08bddf111f..46e89315dd 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt @@ -18,7 +18,7 @@ fun DiaryListItemDto.toDomain(): Diary = content = content, date = date, createdAt = createdAt, - todayMood = todayMood?.toDomain(), + todayMood = todayMood.toDomain(), imageUrl = imageUrl, isDraft = isDraft, ) diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt index 1736b084c7..ea8a79ec23 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DiaryListContractTest.kt @@ -1,17 +1,20 @@ package com.afternote.feature.mindrecord.data.dto import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.MissingFieldException import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Test /** * `GET /diary` 응답 파싱 계약 가드 (Swagger `DiaryResponse` 실측, 2026-08-03). * - * 목록 항목의 필수 프로퍼티가 하나라도 비면 `MissingFieldException` 으로 그 달 일기가 - * 통째로 사라진다 — 실패의 폭이 항목 하나가 아니라 목록 전체라, 필드가 빠져도 살아남는지를 - * 여기서 고정한다. + * 서버가 항상 보내는 응답 키는 기본값으로 은폐하지 않는다 (#789). 키가 빠지면 + * `MissingFieldException` 으로 **실패해야** 계약 변경이 드러나므로, 그 실패를 여기서 고정한다. + * 반대로 클라가 모르는 기분 **값**은 그 칸만 비우고 목록은 살린다 — 키 누락(계약 변경)과 + * 값 확장(표기 변경)은 성격이 다르다. * * `date`(사용자가 고른 일기 날짜)와 `createdAt`(레코드 생성 시각)은 **별개 필드**다. * 한 프로퍼티에 묶으면 서버의 키 순서에 따라 값이 뒤바뀌므로 분리 상태를 함께 고정한다. @@ -73,8 +76,9 @@ class DiaryListContractTest { """ { "status": 200, "code": 200, - "data": { "diaries": [{ "id": 123, "date": "2026-03-21", - "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true }] } + "data": { "diaries": [{ "id": 123, "date": "2026-03-21", "createdAt": "2026.03.21 토", + "title": "t", "content": "c", "todayMood": "SOSO", "isDraft": true }], + "monthDiaryCount": 1, "weeklyDominantMood": null } } """.trimIndent() @@ -87,49 +91,102 @@ class DiaryListContractTest { } @Test - fun `date 가 없으면 null 로 남아 표시 단계가 createdAt 으로 폴백할 수 있다`() { + fun `date 가 빠지면 빈 문자열로 성공하지 않고 실패한다`() { + // 사용자가 고른 일기 날짜는 서버가 항상 채운다. 기본값으로 접히면 캘린더에서 + // 그 기록이 조용히 사라지므로, 누락은 파싱 실패로 드러나야 한다 (#789). val body = """ { "status": 200, "code": 200, - "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "createdAt": "2026.03.21 토", "todayMood": "SAD", "isDraft": false }] } + } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } + } + + @Test + fun `isDraft 가 빠지면 임시저장 아님으로 접히지 않고 실패한다`() { + // `false` 로 접히면 임시저장 일기가 목록에 그대로 노출된다 (#789). + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", "date": "2026-03-21", "createdAt": "2026.03.21 토", "todayMood": "SAD" }] } } """.trimIndent() - val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } + } - val diary = decoded.data!!.diaries.single() - assertNull(diary.date) - assertEquals("2026.03.21 토", diary.createdAt) + @Test + fun `todayMood 가 빠지면 실패한다`() { + // 저장 컬럼이 필수라 응답에도 항상 있다. 한글 값이 관측된 쪽은 AI 가 매기는 + // `emotion` 이지 사용자가 고르는 이 필드가 아니다 (#591, #789). + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 1, "weeklyDominantMood": null, + "diaries": [{ "diaryId": 1, "title": "t", "content": "c", "date": "2026-03-21", + "createdAt": "2026-03-21", "isDraft": false }] } + } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } } @Test - fun `클라가 모르는 기분 값이 와도 그 항목만 기분이 비고 목록은 살아남는다`() { - // 명세 예시에 적힌 "SMILE" 은 클라 enum(HAPPY·SOSO·SAD)에 없다. + fun `그 주에 기록이 없으면 오는 명시적 null 최빈 기분은 정상 파싱된다`() { + // 실제로 `null` 이 오는 필드라 nullable 은 유지한다 — 다만 키 자체는 계약이다 (#789). val body = """ { "status": 200, "code": 200, - "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", - "createdAt": "2026-03-21", "todayMood": "SMILE" }] } + "data": { "monthDiaryCount": 0, "weeklyDominantMood": null, "diaries": [] } } """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) - val diary = decoded.data!!.diaries.single() - assertEquals(1L, diary.diaryId) - assertNull(diary.todayMood) + assertNull(decoded.data!!.weeklyDominantMood) + assertEquals(emptyList(), decoded.data!!.diaries) } @Test - fun `diaries 키가 없으면 빈 목록으로 접힌다`() { - // 0 건과 "키 자체가 없음" 이 같은 결과로 수렴하는 지점 — 회귀 시 여기부터 의심한다. - val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" + fun `클라가 모르는 최빈 기분 값은 그 칸만 비우고 목록은 살아남는다`() { + // 값 확장은 계약 누락과 다르다 — 이모지 한 칸 대신 그 달 목록 전체를 날릴 일이 아니다. + val body = + """ + { + "status": 200, "code": 200, + "data": { "monthDiaryCount": 0, "weeklyDominantMood": "SMILE", "diaries": [] } + } + """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) - assertEquals(emptyList(), decoded.data!!.diaries) + assertNull(decoded.data!!.weeklyDominantMood) + } + + @Test + fun `diaries 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { + // 0 건은 `"diaries": []` 로 온다. 키 자체가 사라진 건 계약이 바뀐 것이라, + // 빈 목록으로 수렴시키면 "기록 없음" 화면과 구분되지 않는다 (#789). + val body = """{ "status": 200, "code": 200, "data": { "monthDiaryCount": 0 } }""" + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(DiaryListDto.serializer()), body) + } } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt new file mode 100644 index 0000000000..e12aea4f3c --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt @@ -0,0 +1,154 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * DTO 기본값·nullable 정리 계약 가드 (#789). + * + * 서버가 항상 채우는 응답 키를 `false`·`0`·`""`·`emptyList()` 로 보정하면, 응답 키 누락과 + * 계약 변경이 파싱 실패가 아니라 **정상적인 빈 값**으로 바뀐다. 이 파일은 그 경계를 고정한다. + * + * - 키 누락 → `MissingFieldException` (계약이 바뀐 것) + * - 실제 의미가 있는 명시적 `null` → 정상 파싱 (값이 없는 상태) + * - 클라가 모르는 기분 **값** → 그 칸만 비움 (표기가 늘어난 것) + * + * Json 설정은 `NetworkModule.provideJson` 과 동일 (ignoreUnknownKeys + coerceInputValues). + */ +class MindRecordDtoContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + // ---------- 데일리질문 목록 ---------- + + @Test + fun `데일리질문 목록의 isDraft 가 빠지면 실패한다`() { + // `false` 로 접히면 임시저장 답변이 목록에 노출된다. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 1, "title": "t", "content": "c", + "createdAt": "2026.08.23 일" }] } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) + } + } + + @Test + fun `계약에 없는 imageUrl 은 빠져도 파싱을 깨지 않는다`() { + // Swagger `DailyQuestionListResponse` 에 없는 필드 — 기본값을 유지하는 유일한 자리. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 1, "title": "t", "content": "c", + "createdAt": "2026.08.23 일", "isDraft": false }] } + """.trimIndent() + + val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) + + assertNull(decoded.data!!.single().imageUrl) + } + + // ---------- 수신자 목록 ---------- + + @Test + fun `수신자 일기 목록은 발신자와 같은 계약이라 키가 빠지면 실패한다`() { + // 두 API 모두 Swagger `DiaryResponse` 를 쓴다 — nullable 과 기본값도 같이 맞춘다. + val body = + """ + { "status": 200, "code": 200, + "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "isDraft": false, "createdAt": "2026.08.23 일", "updatedAt": "2026.08.23 일" }] } } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(ReceiverDiaryListDto.serializer()), body) + } + } + + @Test + fun `수신자 일기 목록 전체 필드는 정상 파싱된다`() { + val body = + """ + { "status": 200, "code": 200, + "data": { "diaries": [{ "diaryId": 1, "title": "t", "content": "c", + "isDraft": false, "todayMood": "HAPPY", "date": "2026-08-23", + "createdAt": "2026.08.23 일", "updatedAt": "2026.08.23 일" }] } } + """.trimIndent() + + val diary = + json + .decodeFromString(BaseResponse.serializer(ReceiverDiaryListDto.serializer()), body) + .data!! + .diaries + .single() + + assertEquals(TodayMoodDto.HAPPY, diary.todayMood) + assertEquals("2026-08-23", diary.date) + } + + @Test + fun `수신자 목록의 래퍼 키가 빠지면 빈 목록으로 접히지 않고 실패한다`() { + assertThrows(MissingFieldException::class.java) { + json.decodeFromString( + BaseResponse.serializer(ReceiverDailyQuestionListDto.serializer()), + """{ "status": 200, "code": 200, "data": { } }""", + ) + } + } + + // ---------- 요청 직렬화 ---------- + + @Test + fun `수신자를 고르지 않으면 빈 목록이 그대로 나간다`() { + // 생성 API 는 `null` 과 `[]` 를 모두 "수신자 없음" 으로 정규화한다. 작성 UI 가 항상 + // 목록을 갖고 있으므로 nullable 로 낮추지 않고 빈 목록을 보낸다 (#789). + val encoded = + json.encodeToString( + DiaryCreateRequestDto.serializer(), + DiaryCreateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + imageUrl = null, + receiverIds = emptyList(), + ), + ) + + assertEquals(true, encoded.contains("\"receiverIds\":[]")) + } + + @Test + fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() { + // 선택 필드라 nullable 은 유지하되 기본값은 없다 — 호출부가 모든 인자를 적어야 한다. + val encoded = + json.encodeToString( + DailyQuestionUpdateRequestDto.serializer(), + DailyQuestionUpdateRequestDto( + content = "수정 본문", + isDraft = false, + date = null, + questionId = null, + imageUrl = null, + ), + ) + + assertEquals(true, encoded.contains("\"content\":\"수정 본문\"")) + assertEquals(true, encoded.contains("\"isDraft\":false")) + } + + private companion object { + val ListSerializerOf = kotlinx.serialization.builtins.ListSerializer(DailyQuestionListItemDto.serializer()) + } +} diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt index 921f39e71e..639ee859eb 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/TodayDailyQuestionContractTest.kt @@ -82,4 +82,30 @@ class TodayDailyQuestionContractTest { assertEquals(true, dto.isAnswered) assertEquals(true, dto.isDraft) } + + @Test + fun `isAnswered 키가 빠지면 미답변으로 접히지 않고 실패한다`() { + // 기본값 `false` 를 두면 #548 같은 키 불일치가 다시 나도 "아직 답 안 함" 인 + // 정상 응답으로 조용히 통과한다 — 오늘의 질문 카드가 계속 미답변으로 보인다 (#789). + assertThrows(MissingFieldException::class.java) { + decode( + """ + { "status": 200, "code": 200, + "data": { "questionId": 32, "day": 13, "content": "q", "isDraft": false } } + """.trimIndent(), + ) + } + } + + @Test + fun `isDraft 키가 빠지면 실패한다`() { + assertThrows(MissingFieldException::class.java) { + decode( + """ + { "status": 200, "code": 200, + "data": { "questionId": 32, "day": 13, "content": "q", "isAnswered": false } } + """.trimIndent(), + ) + } + } } diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt index 5f29bcc149..8008ae7544 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt @@ -33,10 +33,14 @@ class WeeklyReportContractTest { coerceInputValues = true } + // 카운트·요약·목록 키는 모두 계약이라 기본값이 없다 (#789) — 주(week) 형태만 바꿔가며 + // 보기 위해 나머지 필수 키는 여기서 채운다. private fun decodeWeek(weekJson: String): List { val body = """ - { "status": 200, "code": 200, "data": { "week": $weekJson } } + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [], "week": $weekJson } } """.trimIndent() return json .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) @@ -59,8 +63,8 @@ class WeeklyReportContractTest { val week = decodeWeek( """ - [{ "diaryId": 1, "day": 1, "type": "DAILY_QUESTION" }, - { "diaryId": 2, "day": 2, "type": "DEEP_THOUGHT" }] + [{ "diaryId": 1, "day": 1, "type": "DAILY_QUESTION", "emotion": null }, + { "diaryId": 2, "day": 2, "type": "DEEP_THOUGHT", "emotion": null }] """.trimIndent(), ) @@ -70,7 +74,7 @@ class WeeklyReportContractTest { @Test fun `명세에 없는 새 종류가 와도 그 주가 죽지 않고 일기 아님으로 접힌다`() { // type 을 enum 으로 받으면 여기서 그 주 전체가 MissingFieldException 으로 날아간다. - val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND" }]""") + val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "FUTURE_KIND", "emotion": null }]""") assertEquals(false, week.single().toDomain().isDiary) } @@ -79,7 +83,7 @@ class WeeklyReportContractTest { fun `type 키가 빠진 응답은 실패한다`() { // 명세가 required 로 선언한 값이라 조용히 기본값으로 성공시키지 않는다. assertThrows(MissingFieldException::class.java) { - decodeWeek("""[{ "diaryId": 1, "day": 1 }]""") + decodeWeek("""[{ "diaryId": 1, "day": 1, "emotion": null }]""") } } @@ -87,13 +91,14 @@ class WeeklyReportContractTest { fun `종전 isDiary 키만 온 형태는 더 이상 통과하지 않는다`() { // 회귀 방향 고정 — isDiary 로 되돌리면 이 테스트가 먼저 깨진다. assertThrows(MissingFieldException::class.java) { - decodeWeek("""[{ "diaryId": 1, "day": 1, "isDiary": true }]""") + decodeWeek("""[{ "diaryId": 1, "day": 1, "isDiary": true, "emotion": null }]""") } } @Test fun `클라가 모르는 기분 값은 그 원소의 기분만 비운다`() { - // 서버가 한글 감정을 내려주는 중이다 (#591). + // 서버가 한글 감정을 내려준 전례가 있다 (#591). #789 로 기본값을 걷어냈어도 + // **값** 확장까지 실패로 만들지는 않는다 — 이모지 한 칸 대신 그 주가 통째로 날아간다. val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DIARY", "emotion": "슬픔" }]""") val day = week.single().toDomain() @@ -102,11 +107,49 @@ class WeeklyReportContractTest { } @Test - fun `week 키가 없으면 빈 목록으로 접힌다`() { - val body = """{ "status": 200, "code": 200, "data": { "summaryText": "" } }""" + fun `emotion 이 명시적 null 이면 정상 파싱된다`() { + // 일기가 아닌 기록에는 기분이 없다 — 실제 의미가 있는 null 이라 nullable 은 유지한다. + val week = decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DEEP_THOUGHT", "emotion": null }]""") - val decoded = json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + assertNull(week.single().emotion) + } + + @Test + fun `emotion 키 자체가 빠지면 실패한다`() { + // 값이 조건부인 것과 키가 사라진 것은 다르다 — 후자는 계약 변경이라 드러나야 한다 (#789). + assertThrows(MissingFieldException::class.java) { + decodeWeek("""[{ "diaryId": 1, "day": 1, "type": "DIARY" }]""") + } + } + + @Test + fun `week 키가 없으면 빈 목록으로 접히지 않고 실패한다`() { + // 기록 0 건은 `"week": []` 로 온다. 키가 사라진 건 계약이 바뀐 것이라, + // 빈 목록으로 수렴시키면 "이번 주 기록 없음" 리포트와 구분되지 않는다 (#789). + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [] } } + """.trimIndent() - assertEquals(emptyList(), decoded.data!!.week) + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + } + } + + @Test + fun `카운트 키가 빠지면 0 으로 접히지 않고 실패한다`() { + // `0` 으로 접히면 잘못된 주간 리포트가 정상 화면으로 보인다 (#789). + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "summaryText": "", + "daily-question": [], "emotions": [], "week": [] } } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + } } } diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt index 4b325fea07..9ac8f68e14 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt @@ -10,11 +10,11 @@ data class Diary( val diaryId: Long, val title: String, val content: String, - /** 사용자가 고른 일기 날짜. 서버가 주지 않으면 null 이라 [createdAt] 으로 폴백한다. */ - val date: String?, + /** 사용자가 고른 일기 날짜 (yyyy-MM-dd). 서버가 항상 채우는 값이다 (#789). */ + val date: String, val createdAt: String, - /** 서버가 기분을 주지 않았거나 클라가 모르는 값이면 null — 목록 표시에서 이모지만 생략한다. */ - val todayMood: TodayMood?, + /** 사용자가 직접 고른 오늘의 기분. 저장 컬럼이 필수라 응답에도 항상 있다 (#789). */ + val todayMood: TodayMood, val imageUrl: String? = null, val isDraft: Boolean = false, ) @@ -31,8 +31,8 @@ data class DiaryCreatePayload( val isDraft: Boolean, val todayMood: TodayMood, val imageUrl: String? = null, - /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 null (서버 기본 동작). */ - val receiverIds: List? = null, + /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 빈 목록 — 서버가 "수신자 없음" 으로 본다. */ + val receiverIds: List, ) data class DiaryUpdatePayload( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt index 33c461f8d9..276607d739 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt @@ -45,7 +45,7 @@ fun Diary.toUi(): DailyDiary? { title = title, date = resolvedDate, content = content, - emotion = todayMood?.toEmoji(), + emotion = todayMood.toEmoji(), imageUrl = imageUrl, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 9743c1e537..3f3199ca97 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -117,7 +117,7 @@ class DiaryWriteViewModel isDraft = isDraft, todayMood = mood, imageUrl = state.imageUrl, - receiverIds = state.selectedReceiverIds.toList().takeIf { it.isNotEmpty() }, + receiverIds = state.selectedReceiverIds.toList(), ), ) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt index e6ac127d37..67aec8d37d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapperTest.kt @@ -24,7 +24,7 @@ class MindRecordUiMapperTest { @Test fun `date 가 없으면 createdAt 으로 폴백한다`() { - val ui = diary(date = null, createdAt = "2026.03.25 수").toUi() + val ui = diary(date = "", createdAt = "2026.03.25 수").toUi() assertEquals(LocalDate.of(2026, 3, 25), ui?.date) } @@ -32,15 +32,16 @@ class MindRecordUiMapperTest { @Test fun `ISO 날짜시각 createdAt 도 파싱된다`() { // Swagger DiaryResponse 예시가 `createdAt: "2026-03-25T20:13:42"` 형태다. - val ui = diary(date = null, createdAt = "2026-03-25T20:13:42").toUi() + val ui = diary(date = "", createdAt = "2026-03-25T20:13:42").toUi() assertEquals(LocalDate.of(2026, 3, 25), ui?.date) } @Test fun `두 날짜가 모두 없으면 오늘로 메우지 않고 항목을 뺀다`() { - // createdAt 은 이 PR 에서 기본값 ""를 얻었으므로 둘 다 비는 응답이 실제로 가능하다. - assertNull(diary(date = null, createdAt = "").toUi()) + // #789 이후 두 필드 모두 계약이라 빈 응답은 파싱 단계에서 걸러지지만, 표시 단계가 + // 여전히 날짜 없는 항목을 오늘로 메우지 않는다는 것은 그대로 고정해 둔다. + assertNull(diary(date = "", createdAt = "").toUi()) } @Test @@ -49,7 +50,7 @@ class MindRecordUiMapperTest { } private fun diary( - date: String?, + date: String, createdAt: String, ) = Diary( diaryId = 1L,