From 9c37f1105d6c234b9e6fc70b0d8eca732077238c Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 21:34:01 +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=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B8=EB=AC=B8=20img=20=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `imageUrl` 이 서버 계약에 없어 첨부한 이미지가 저장되지도 조회되지도 않았다. 요청에 실어 보내도 서버가 무시하고, 응답에도 그 키가 없어 목록 카드 썸네일이 영영 뜨지 않았다. 계약에 없는 `imageUrl` 을 요청·응답·도메인·작성 상태에서 걷어내고, 서버 설계대로 본문 `content` HTML 의 `img` 태그 하나로 모은다. 목록 카드 썸네일은 본문의 첫 `img[src]` 에서 뽑는다. 저장 시 나가는 `src` 는 업로드 URL 이 아니라 **fileKey** 다. 서버는 본문 img 를 훑어 staging 파일을 permanent 로 옮기고 전체 URL 로 재작성하는데, 전체 URL 을 넣으면 그 앞에 호스트를 한 번 더 붙여 접근 불가한 주소가 저장된다. 미리보기는 전체 URL 이라야 뜨므로 에디터에는 URL 을 넣고 제출 직전에만 키로 바꾼다. 이미 저장된 영구 URL 은 그대로 통과시킨다. Closes #549 --- .../mindrecord/data/dto/DailyQuestionDto.kt | 13 +- .../data/mapper/DailyQuestionMapper.kt | 3 - .../dto/DailyQuestionImageContractTest.kt | 95 +++++++++++++++ .../domain/model/DailyQuestionModels.kt | 3 - .../presentation/mapper/MindRecordUiMapper.kt | 4 +- .../mindrecord/presentation/util/HtmlText.kt | 42 +++++++ .../viewmodel/DailyQuestionWriteUiState.kt | 1 - .../viewmodel/DailyQuestionWriteViewModel.kt | 37 ++++-- .../mapper/MindRecordUiMapperTest.kt | 26 ++++ .../presentation/util/HtmlImageSrcTest.kt | 82 +++++++++++++ .../DailyQuestionWriteViewModelTest.kt | 111 ++++++++++++++---- 11 files changed, 374 insertions(+), 43 deletions(-) create mode 100644 feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.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..6fed30a564 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 @@ -7,21 +7,26 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames +/** + * `POST /daily-questions` 요청 (Swagger `DailyQuestionAnswerRequest`). + * + * **`imageUrl` 필드는 없다.** 요청·응답 계약 어디에도 없고, 보내도 서버가 무시한다 + * (2026-08-23 실측). 본문 이미지는 [content] HTML 의 `img` 태그로 담긴다 (#549). + */ @Serializable data class DailyQuestionCreateRequestDto( @SerialName("content") val content: String, @SerialName("isDraft") val isDraft: Boolean, @SerialName("questionId") val questionId: Long, - @SerialName("imageUrl") val imageUrl: String? = null, ) +/** `PATCH /daily-questions/{id}` 요청. [DailyQuestionCreateRequestDto] 와 같은 이유로 `imageUrl` 이 없다. */ @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, ) @Serializable @@ -35,7 +40,9 @@ data class DailyQuestionListItemDto( @SerialName("title") val title: String, @SerialName("content") val content: String, @SerialName("createdAt") val createdAt: String, - @SerialName("imageUrl") val imageUrl: String? = null, + // `imageUrl` 은 응답 계약에 없고 실서버도 키 자체를 내려주지 않는다 — 항상 null 인 + // 필드를 읽느라 목록 카드 썸네일이 영영 뜨지 않았다. 썸네일은 표시 단계에서 + // `content` 의 첫 img 로 뽑는다 (#549). // Swagger `DailyQuestionListResponse` 및 실서버 응답 모두 와이어 키는 `isDraft`. // 과거 QA logcat 에서 `draft` 로 관측된 적이 있어 대체 키도 함께 허용한다. @SerialName("isDraft") diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt index f1ac388ccb..2b18f82112 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DailyQuestionMapper.kt @@ -15,7 +15,6 @@ fun DailyQuestionListItemDto.toDomain(): DailyQuestion = title = title, content = content, createdAt = createdAt, - imageUrl = imageUrl, isDraft = isDraft, ) @@ -33,7 +32,6 @@ fun DailyQuestionCreatePayload.toRequest(): DailyQuestionCreateRequestDto = content = content, isDraft = isDraft, questionId = questionId, - imageUrl = imageUrl, ) fun DailyQuestionUpdatePayload.toRequest(): DailyQuestionUpdateRequestDto = @@ -42,5 +40,4 @@ fun DailyQuestionUpdatePayload.toRequest(): DailyQuestionUpdateRequestDto = isDraft = isDraft, date = date, questionId = questionId, - imageUrl = imageUrl, ) diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt new file mode 100644 index 0000000000..888a086794 --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/DailyQuestionImageContractTest.kt @@ -0,0 +1,95 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * 데일리질문 이미지 계약 가드 (#549). + * + * Swagger 전체에서 이미지 필드는 `UserResponse.profileImageUrl` 과 + * `MusicSearchItemDto.albumImageUrl` 뿐이고, 데일리질문의 요청·응답 스키마 어디에도 + * `imageUrl` 이 없다. 대신 `content` 설명이 허용 태그로 `img[src|alt|width|height|style]` + * 을 명시한다 — 본문 이미지는 `content` HTML 에 담긴다. + * + * 실서버 왕복 실측(2026-08-23)에서도 `imageUrl` 은 무시되고 `` 는 그대로 살아남았다. + */ +class DailyQuestionImageContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Test + fun `등록 요청에 imageUrl 을 싣지 않는다`() { + // 계약에 없는 필드라 보내 봐야 서버가 버린다. 필드를 두면 "저장됐다" 는 착각만 남는다. + val encoded = + json.encodeToString( + DailyQuestionCreateRequestDto.serializer(), + DailyQuestionCreateRequestDto(content = "

본문

", isDraft = false, questionId = 1L), + ) + + assertFalse(encoded.contains("imageUrl")) + } + + @Test + fun `수정 요청에도 imageUrl 을 싣지 않는다`() { + val encoded = + json.encodeToString( + DailyQuestionUpdateRequestDto.serializer(), + DailyQuestionUpdateRequestDto(content = "

본문

", isDraft = true), + ) + + assertFalse(encoded.contains("imageUrl")) + } + + @Test + fun `본문의 img 태그는 응답에서 그대로 돌아온다`() { + // 실서버 왕복 실측 형태 — 이미지가 남는 유일한 경로다. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 26, "title": "질문", "isDraft": false, + "createdAt": "2026.08.23 일", + "content": "

사진과 함께

\"사진\"

" }] } + """.trimIndent() + + val item = + json + .decodeFromString( + BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), + body, + ).data!! + .single() + + assertEquals( + "

사진과 함께

\"사진\"

", + item.content, + ) + } + + @Test + fun `응답에 없는 imageUrl 키를 읽지 않아도 파싱은 멀쩡하다`() { + // 실서버 응답에는 이 키 자체가 없다 — 읽어 봐야 항상 null 이라 썸네일이 영영 안 떴다. + val body = + """ + { "status": 200, "code": 200, + "data": [{ "userDailyQuestionId": 1, "title": "질문", "content": "

본문

", + "createdAt": "2026.08.23 일", "isDraft": false }] } + """.trimIndent() + + val item = + json + .decodeFromString( + BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())), + body, + ).data!! + .single() + + assertEquals(1L, item.dailyQuestionId) + } +} diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt index 9391bc3639..0cf54fa9f0 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DailyQuestionModels.kt @@ -5,7 +5,6 @@ data class DailyQuestion( val title: String, val content: String, val createdAt: String, - val imageUrl: String? = null, val isDraft: Boolean = false, ) @@ -22,7 +21,6 @@ data class DailyQuestionCreatePayload( val content: String, val isDraft: Boolean, val questionId: Long, - val imageUrl: String? = null, ) data class DailyQuestionUpdatePayload( @@ -30,5 +28,4 @@ data class DailyQuestionUpdatePayload( val isDraft: Boolean? = null, val date: String? = null, val questionId: Long? = null, - val imageUrl: String? = null, ) 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..97b9022040 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 @@ -4,6 +4,7 @@ import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.presentation.model.DailyDiary import com.afternote.feature.mindrecord.presentation.model.DailyQuestion +import com.afternote.feature.mindrecord.presentation.util.firstHtmlImageSrcOrNull import java.time.LocalDate import java.time.format.DateTimeFormatter import com.afternote.feature.mindrecord.domain.model.DailyQuestion as DailyQuestionDomain @@ -25,7 +26,8 @@ fun DailyQuestionDomain.toUi(): DailyQuestion = title = title, date = parseLocalDate(createdAt), content = content, - imageUrl = imageUrl, + // 서버 계약에 `imageUrl` 이 없다 — 썸네일은 본문 HTML 의 첫 img 에서 뽑는다 (#549). + imageUrl = content.firstHtmlImageSrcOrNull(), ) /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index f4a1d33560..0ca34b6ac2 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -14,3 +14,45 @@ fun String.htmlToPlainText(): String = .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) .toString() .trim() + +/** + * 본문 HTML 의 **첫 `img[src]`** 를 목록 카드 썸네일로 뽑는다 (없으면 null). + * + * 데일리질문·일기의 요청/응답 계약 어디에도 `imageUrl` 필드가 없다 (#549). 서버 설계는 + * 본문 이미지를 `content` HTML 의 `img` 태그로 담는 것이고, `content` 설명이 허용 태그로 + * `img[src|alt|width|height|style]` 을 명시한다. 실서버에서도 `` 는 저장·재조회에 + * 그대로 살아남는다 (2026-08-23 실측). + * + * 그래서 썸네일의 출처는 별도 필드가 아니라 본문이다. 정규식으로 훑는 이유는 이 값이 + * **표시용 한 장**이라, 파서를 들이기보다 실패해도 썸네일이 안 뜨는 정도로 끝나는 편이 + * 낫기 때문이다 — 본문 렌더링은 여전히 에디터/`htmlToPlainText` 가 맡는다. + */ +fun String.firstHtmlImageSrcOrNull(): String? = + HTML_IMG_SRC + .find(this) + ?.groupValues + ?.get(1) + ?.trim() + ?.takeIf { it.isNotEmpty() } + +// `src` 가 img 태그의 몇 번째 속성이든 잡히도록 태그 안을 훑는다. 따옴표는 " 와 ' 둘 다 허용. +private val HTML_IMG_SRC = Regex("""]*?\bsrc\s*=\s*["']([^"']*)["']""", RegexOption.IGNORE_CASE) + +/** + * 업로드 직후 받은 파일 URL 을 **서버가 본문에서 기대하는 형태**(fileKey) 로 바꾼다. + * + * `POST /files/presigned-url` 은 `fileKey`(`mindrecords/staging/13/uuid.png`) 와 + * `fileUrl`(그 앞에 CDN 호스트가 붙은 전체 URL) 을 함께 준다. 저장 시 서버는 본문의 + * `img src` 를 훑어 **staging 파일을 permanent 로 옮기고 전체 URL 로 재작성**하는데, + * 이때 기대하는 입력이 fileKey 다. 전체 URL 을 그대로 넣으면 그 앞에 호스트를 한 번 더 + * 붙여 버린다 (실서버 실측 2026-08-23, #549). + * + * ``` + * 보낸 값: https://cdn.example.net/mindrecords/staging/13/a.png + * 저장된 값: https://cdn.example.net/https://cdn.example.net/mindrecords/permanent/13/a.png → 403 + * ``` + * + * 스킴과 호스트만 떼면 fileKey 가 되므로 경로 규칙(`mindrecords/staging/...`)을 코드에 + * 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다. + */ +fun String.toUploadedFileKey(): String = substringAfter("://").substringAfter('/') diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 5e5462c85f..0f4f8ec4f7 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -10,7 +10,6 @@ data class DailyQuestionWriteUiState( /** 오늘 이미 임시저장된 답변 레코드 ID — null 이 아니면 제출 시 POST 대신 PATCH 로 전환한다. */ val draftId: Long? = null, val answer: String = "", - val imageUrl: String? = null, val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, val submitState: SubmitState = SubmitState.Idle, 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..b82801f59a 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 @@ -9,6 +9,7 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -90,25 +91,43 @@ class DailyQuestionWriteViewModel it.copy( draftId = draft.dailyQuestionId, answer = if (it.answer.isBlank()) draft.content else it.answer, - imageUrl = it.imageUrl ?: draft.imageUrl, ) } } + /** 이번 작성 중 업로드한 이미지의 원본 URL. 제출 시 fileKey 로 바꿀 대상이다 (#549). */ + private val uploadedImageUrls = mutableSetOf() + fun onAnswerChanged(text: String) { _uiState.update { it.copy(answer = text) } } /** - * 에디터에서 고른 이미지를 presigned URL 로 업로드하고 영구 URL 을 반환한다 (실패 시 null). - * 첫 업로드 이미지는 등록 payload 의 `imageUrl` (목록 카드 썸네일) 로도 쓴다. + * 에디터에서 고른 이미지를 업로드하고 **미리보기에 쓸 URL** 을 반환한다 (실패 시 null). + * + * 반환한 URL 은 에디터가 본문 HTML 에 `` 로 삽입한다 — 그것이 서버에 + * 이미지가 남는 유일한 경로다. 종전에는 이 URL 을 등록 payload 의 `imageUrl` 로도 + * 실어 보냈지만, 그 필드는 계약에 없어 서버가 통째로 무시했다 (#549). + * + * 다만 **저장 시 나가는 값은 이 URL 이 아니다.** 서버는 본문의 `img src` 에서 fileKey + * 를 기대하므로, 여기서 받은 URL 을 [uploadedImageUrls] 에 기억해 뒀다가 제출 직전에 + * 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다. */ suspend fun uploadImage(uriString: String): String? = photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) - .onSuccess { url -> - _uiState.update { if (it.imageUrl == null) it.copy(imageUrl = url) else it } - }.getOrNull() + .onSuccess { url -> uploadedImageUrls += url } + .getOrNull() + + /** + * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 만 fileKey 로 바꾼다. + * + * 이미 저장돼 본문에 들어 있는 영구 URL 은 건드리지 않는다 — 서버가 그대로 통과시키고 + * (실측 확인), 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그래서 경로 + * 패턴으로 훑지 않고 이번에 받은 URL 만 정확히 치환한다. + */ + private fun String.toWireContent(): String = + uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) } /** * 저장(`isDraft=false`) / 임시저장(`isDraft=true`). @@ -145,19 +164,17 @@ class DailyQuestionWriteViewModel id = state.draftId, payload = DailyQuestionUpdatePayload( - content = state.answer, + content = state.answer.toWireContent(), isDraft = isDraft, questionId = questionId, - imageUrl = state.imageUrl, ), ) } else { repository.create( DailyQuestionCreatePayload( - content = state.answer, + content = state.answer.toWireContent(), isDraft = isDraft, questionId = questionId, - imageUrl = state.imageUrl, ), ) } 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..bc93077942 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 @@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test import java.time.LocalDate +import com.afternote.feature.mindrecord.domain.model.DailyQuestion as DailyQuestionDomain /** * [Diary.toUi] 날짜 해석 계약 가드 (#699). @@ -59,4 +60,29 @@ class MindRecordUiMapperTest { createdAt = createdAt, todayMood = TodayMood.HAPPY, ) + + @Test + fun `데일리질문 썸네일은 본문 HTML 의 첫 이미지에서 나온다`() { + // 서버 계약에 `imageUrl` 이 없어 응답 필드로는 영영 null 이다 — 본문에서 뽑지 않으면 + // 이미지를 첨부해도 목록 카드 썸네일이 뜨지 않는다 (#549). + val ui = + dailyQuestion(content = "

사진과 함께

").toUi() + + assertEquals("https://cdn/a.png", ui.imageUrl) + } + + @Test + fun `본문에 이미지가 없으면 썸네일 없이 텍스트 카드로 간다`() { + val ui = dailyQuestion(content = "

글만 있는 답변

").toUi() + + assertNull(ui.imageUrl) + } + + private fun dailyQuestion(content: String) = + DailyQuestionDomain( + dailyQuestionId = 1L, + title = "오늘의 질문", + content = content, + createdAt = "2026.08.23 일", + ) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt new file mode 100644 index 0000000000..c290208e29 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt @@ -0,0 +1,82 @@ +package com.afternote.feature.mindrecord.presentation.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 본문 HTML 에서 목록 카드 썸네일을 뽑는 규칙 (#549). + * + * 데일리질문의 요청/응답 계약에 `imageUrl` 이 없어, 썸네일의 출처는 별도 필드가 아니라 + * `content` 다. 에디터가 넣는 형태와 서버 sanitize 를 거친 형태 모두에서 뽑혀야 한다. + */ +class HtmlImageSrcTest { + @Test + fun `에디터가 삽입하는 형태에서 뽑는다`() { + // WriteTextField 가 업로드 URL 을 이 형태로 본문 끝에 붙인다. + val html = "

사진과 함께

" + + assertEquals("https://cdn.example.com/a.png", html.firstHtmlImageSrcOrNull()) + } + + @Test + fun `서버 sanitize 를 거쳐 alt 가 붙은 형태에서도 뽑는다`() { + // 실서버 왕복 실측(2026-08-23) 형태 — src 앞뒤로 다른 속성이 붙는다. + val html = + "

본문

\"사진\"

" + + assertEquals("https://afternote-bucket.s3.amazonaws.com/probe.png", html.firstHtmlImageSrcOrNull()) + } + + @Test + fun `src 가 뒤쪽 속성이어도 뽑는다`() { + val html = "\"사진\"" + + assertEquals("https://cdn.example.com/b.png", html.firstHtmlImageSrcOrNull()) + } + + @Test + fun `작은따옴표도 허용한다`() { + assertEquals("https://cdn.example.com/c.png", "".firstHtmlImageSrcOrNull()) + } + + @Test + fun `여러 장이면 첫 장을 쓴다`() { + val html = "" + + assertEquals("https://cdn.example.com/1.png", html.firstHtmlImageSrcOrNull()) + } + + @Test + fun `이미지가 없으면 null 이라 텍스트 카드로 그린다`() { + assertNull("

글만 있는 본문

".firstHtmlImageSrcOrNull()) + } + + @Test + fun `img 를 흉내 낸 다른 태그에는 걸리지 않는다`() { + // ``·`` 같은 이름은 img 가 아니다 — 단어 경계로 막는다. + assertNull("".firstHtmlImageSrcOrNull()) + } + + @Test + fun `src 가 비어 있으면 썸네일로 쓰지 않는다`() { + // 빈 문자열을 넘기면 이미지 카드 레이아웃으로 그려 놓고 아무것도 안 뜬다. + assertNull("\"사진\"".firstHtmlImageSrcOrNull()) + } + + @Test + fun `업로드 URL 은 스킴과 호스트를 떼 fileKey 가 된다`() { + // 서버가 본문 img src 에서 기대하는 형태 — 전체 URL 을 넣으면 호스트를 한 번 더 붙인다. + val url = "https://dpy1u4p7yz7bh.cloudfront.net/mindrecords/staging/13/a.png" + + assertEquals("mindrecords/staging/13/a.png", url.toUploadedFileKey()) + } + + @Test + fun `호스트가 바뀌어도 경로 규칙을 코드에 박지 않는다`() { + // 디렉터리 구조를 서버가 바꿔도 스킴·호스트만 떼는 규칙은 그대로 따라간다. + val url = "https://cdn.example.net/some/other/layout/b.png" + + assertEquals("some/other/layout/b.png", url.toUploadedFileKey()) + } +} 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..9f716ce316 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 @@ -91,9 +91,7 @@ class DailyQuestionWriteViewModelTest { Result.success(todayQuestion(isDraft = true)) } }, - onGetList = { - Result.success(listOf(draft(content = "옛 본문", imageUrl = "https://cdn/old.jpg"))) - }, + onGetList = { Result.success(listOf(draft(content = "옛 본문"))) }, ) val viewModel = DailyQuestionWriteViewModel( @@ -105,22 +103,43 @@ class DailyQuestionWriteViewModelTest { runBlocking { viewModel.uploadImage("content://just-picked") } viewModel.submit() - assertEquals("https://cdn/just-picked.jpg", viewModel.uiState.value.imageUrl) + // 업로드 URL 은 에디터가 본문에 로 넣는다 — payload 필드로는 나가지 않는다 (#549). + assertEquals("사용자가 방금 입력한 답변", viewModel.uiState.value.answer) + } + + @Test + fun `업로드한 URL 은 에디터에 돌려줄 뿐 상태에 따로 담지 않는다`() { + // 서버 계약에 `imageUrl` 이 없어 payload 로 보내 봐야 무시된다. 이미지가 남는 + // 유일한 경로는 본문 HTML 의 뿐이라, ViewModel 은 URL 을 돌려주기만 한다 (#549). + val repository = + FakeDailyQuestionRepository( + onGetToday = { Result.success(todayQuestion(isDraft = false)) }, + onGetList = { Result.success(emptyList()) }, + ) + val viewModel = + DailyQuestionWriteViewModel( + repository, + PhotoUploadRepository { _, _ -> Result.success("https://cdn/picked.jpg") }, + ) + + val url = runBlocking { viewModel.uploadImage("content://picked") } + + assertEquals("https://cdn/picked.jpg", url) } @Test - fun `화면에 이미지가 없으면 draft 이미지를 이어받는다`() { + fun `이어쓰기는 본문에 실린 이미지를 그대로 가져온다`() { + // 이미지가 본문 안에 있으므로 이어쓰기가 본문만 복원하면 이미지도 따라온다. + val html = "

이어쓸 본문

" val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = true)) }, - onGetList = { - Result.success(listOf(draft(content = "이어쓸 본문", imageUrl = "https://cdn/old.jpg"))) - }, + onGetList = { Result.success(listOf(draft(content = html))) }, ) val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) - assertEquals("https://cdn/old.jpg", viewModel.uiState.value.imageUrl) + assertEquals(html, viewModel.uiState.value.answer) } @Test @@ -148,23 +167,71 @@ class DailyQuestionWriteViewModelTest { isDraft = isDraft, ) - private fun draft( - content: String, - imageUrl: String? = null, - ) = DailyQuestion( - dailyQuestionId = 7L, - title = "오늘의 질문", - content = content, - createdAt = "2026-08-13", - imageUrl = imageUrl, - isDraft = true, - ) + private fun draft(content: String) = + DailyQuestion( + dailyQuestionId = 7L, + title = "오늘의 질문", + content = content, + createdAt = "2026-08-13", + isDraft = true, + ) + + @Test + fun `저장 시 방금 업로드한 이미지의 src 는 fileKey 로 나간다`() { + // 서버는 본문 img src 에서 fileKey 를 받아 staging→permanent 로 옮기고 전체 URL 로 + // 재작성한다. 전체 URL 을 그대로 보내면 그 앞에 호스트를 한 번 더 붙여 403 이 된다 + // (실서버 실측 2026-08-23, #549). + var sentContent: String? = null + val repository = + FakeDailyQuestionRepository( + onGetToday = { Result.success(todayQuestion(isDraft = false)) }, + onCreate = { payload -> + sentContent = payload.content + Result.success(Unit) + }, + ) + val viewModel = + DailyQuestionWriteViewModel( + repository, + PhotoUploadRepository { _, _ -> + Result.success("https://cdn.example.net/mindrecords/staging/13/a.png") + }, + ) + + val previewUrl = runBlocking { viewModel.uploadImage("content://picked") } + viewModel.onAnswerChanged("

본문

") + viewModel.submit() + + assertEquals("

본문

", sentContent) + } + + @Test + fun `이미 저장된 영구 URL 은 그대로 내보낸다`() { + // 서버가 영구 URL 은 통과시킨다 — 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. + val permanent = "https://cdn.example.net/mindrecords/permanent/13/a.png" + var sentContent: String? = null + val repository = + FakeDailyQuestionRepository( + onGetToday = { Result.success(todayQuestion(isDraft = false)) }, + onCreate = { payload -> + sentContent = payload.content + Result.success(Unit) + }, + ) + val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository) + + viewModel.onAnswerChanged("

수정

") + viewModel.submit() + + assertEquals("

수정

", sentContent) + } } /** 미지정 경로 호출은 error 로 드러낸다 (core:data 의 Fake 들과 같은 규칙). */ private class FakeDailyQuestionRepository( private val onGetToday: () -> Result, - private val onGetList: () -> Result>, + private val onGetList: () -> Result> = { Result.success(emptyList()) }, + private val onCreate: (DailyQuestionCreatePayload) -> Result = { Result.success(Unit) }, ) : DailyQuestionRepository { var createCallCount = 0 private set @@ -178,7 +245,7 @@ private class FakeDailyQuestionRepository( override suspend fun create(payload: DailyQuestionCreatePayload): Result { createCallCount += 1 - return Result.success(Unit) + return onCreate(payload) } override suspend fun update( From a4cd6032949c8ba37da52339d736e37393b0091a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 16:53:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mindrecord):=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=8C=80=EC=B2=B4=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=83=88=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본문에 img 를 정식으로 넣기 시작하면서 드러나는 자리다. HtmlCompat.fromHtml 은 ImageGetter 없이 파싱하면 img 자리에 U+FFFC 를 남기고, 공백이 아니라 trim() 으로는 지워지지 않는다 — 카드가 항상 썸네일 레이아웃으로 가므로 미리보기 둘째 줄이 통째로 대체 문자가 된다. 같은 함수를 일기·임시저장·추억 공간도 쓰므로 함께 덮인다. toUploadedFileKey 에 스킴 없는 입력 가드도 넣는다 — 지금은 도달하지 않지만 실패가 예외가 아니라 «조용히 틀린 값» 으로 나오는 형태다. 제출 성공 시 staging 키 목록도 비운다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/build.gradle.kts | 5 ++ .../mindrecord/presentation/util/HtmlText.kt | 14 +++++- .../viewmodel/DailyQuestionWriteViewModel.kt | 5 ++ .../presentation/util/HtmlImageSrcTest.kt | 46 +++++++++++++++++++ .../DailyQuestionWriteViewModelTest.kt | 2 + 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..724859e906 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -7,6 +7,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" resourcePrefix = "mindrecord_" + + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { @@ -19,4 +21,7 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) + + // 카드 미리보기의 대체 문자 제거는 HtmlCompat 파싱 결과라 실제 파서로 확인한다 (#549). + testImplementation(libs.robolectric) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index 0ca34b6ac2..9609062d60 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -13,6 +13,10 @@ fun String.htmlToPlainText(): String = HtmlCompat .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) .toString() + // ImageGetter 없이 파싱하면 img 자리에 U+FFFC 가 남는다. 본문에 img 를 정식으로 + // 넣기 시작하면(#549) 카드 미리보기 둘째 줄이 통째로 `` 가 된다 — 공백이 아니라 + // trim() 으로는 안 지워진다. + .replace("\uFFFC", "") .trim() /** @@ -54,5 +58,13 @@ private val HTML_IMG_SRC = Regex("""]*?\bsrc\s*=\s*["']([^"']*)["']""", * * 스킴과 호스트만 떼면 fileKey 가 되므로 경로 규칙(`mindrecords/staging/...`)을 코드에 * 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다. + * + * 다만 이 방식은 `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정한다. **권위 있는 + * 출처는 presigned 응답의 `fileKey` 인데 `PhotoUploadRepository.upload()` 가 `fileUrl` 만 + * 돌려주며 버린다** — CDN 이 경로 프리픽스나 쿼리스트링을 붙이면 조용히 틀린 키가 된다. + * 반환을 넓히는 것은 `core:data` 범위라 별건으로 둔다 (#1017). */ -fun String.toUploadedFileKey(): String = substringAfter("://").substringAfter('/') +fun String.toUploadedFileKey(): String = + // 스킴이 없으면 substringAfter("://") 가 원문을 그대로 돌려주고, 이어지는 + // substringAfter('/') 가 첫 경로 세그먼트를 잘라먹는다 — 조용히 틀린 키가 나간다. + if (contains("://")) substringAfter("://").substringAfter('/') else this 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 b82801f59a..4bf85032a8 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 @@ -180,6 +180,11 @@ class DailyQuestionWriteViewModel } result .onSuccess { + // 제출이 성공하면 staging 키는 이미 permanent 로 옮겨졌다. 남겨 두면 + // 같은 ViewModel 로 두 번 제출될 때(예: 임시저장 뒤 화면에 머무는 흐름) + // 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그 전제를 화면 구조가 + // 아니라 여기서 지킨다. + uploadedImageUrls.clear() _uiState.update { it.copy(submitState = SubmitState.Succeeded) } }.onFailure { e -> _uiState.update { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt index c290208e29..1b3a17d1cd 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt @@ -3,6 +3,9 @@ package com.afternote.feature.mindrecord.presentation.util import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * 본문 HTML 에서 목록 카드 썸네일을 뽑는 규칙 (#549). @@ -79,4 +82,47 @@ class HtmlImageSrcTest { assertEquals("some/other/layout/b.png", url.toUploadedFileKey()) } + + @Test + fun `스킴이 없는 값은 첫 경로 세그먼트를 잃지 않는다`() { + // substringAfter("://") 는 구분자가 없으면 원문을 그대로 돌려주고, 이어지는 + // substringAfter('/') 가 mindrecords 를 떼 버린다 — 조용히 틀린 키가 나간다. + assertEquals( + "mindrecords/staging/13/a.png", + "mindrecords/staging/13/a.png".toUploadedFileKey(), + ) + } +} + +/** + * 목록 카드 미리보기에 대체 문자가 새지 않는지 (#549 리뷰 지적). + * + * `HtmlCompat.fromHtml` 은 ImageGetter 없이 파싱하면 `img` 자리에 U+FFFC(OBJECT + * REPLACEMENT CHARACTER)를 남긴다. 본문에 `img` 를 정식으로 넣기 시작하면서 드러나는 + * 자리라, 카드 미리보기 둘째 줄이 통째로 `` 가 된다. 공백이 아니라 `trim()` 으로는 + * 지워지지 않는다. + * + * `HtmlCompat` 을 타서 Robolectric 이 필요하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HtmlPlainTextTest { + @Test + fun `본문 뒤 이미지는 미리보기에 대체 문자를 남기지 않는다`() { + val html = "

본문

" + + assertEquals("본문", html.htmlToPlainText()) + } + + @Test + fun `이미지만 있는 본문은 미리보기가 비어 있다`() { + assertEquals("", "".htmlToPlainText()) + } + + @Test + fun `글과 이미지가 섞여 있어도 글만 남는다`() { + val html = "

앞

뒤

" + + assertEquals(false, html.htmlToPlainText().contains('')) + } } 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 9f716ce316..fdb40aed8f 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 @@ -130,6 +130,8 @@ class DailyQuestionWriteViewModelTest { @Test fun `이어쓰기는 본문에 실린 이미지를 그대로 가져온다`() { // 이미지가 본문 안에 있으므로 이어쓰기가 본문만 복원하면 이미지도 따라온다. + // 여기서 고정하는 것은 ViewModel 단까지다 — 화면은 에디터가 외부 value 를 첫 + // 컴포지션 1회만 시드해서 아직 성립하지 않는다 (#923 소관). val html = "

이어쓸 본문

" val repository = FakeDailyQuestionRepository( From a715885309d09b3ae86fcae14ac2027ebca21a74 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 00:15:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(mindrecord):=20develop=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EB=92=A4=20=EB=82=A8=EC=9D=80=20imageUrl=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=B2=98=EB=A5=BC=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약에 없는 imageUrl 을 데일리질문 DTO·페이로드에서 걷어낸 변경이 app 의 androidTest 페이크까지 닿지 않아 :app:compileDebugAndroidTestKotlin 이 깨졌다. 데일리질문 쪽 imageUrl 대입 3곳을 지운다 — 썸네일은 본문 img 태그에서 뽑는다. 계약 테스트의 기대값이 픽스처와 어긋나 있던 것(title "t" 를 "질문" 으로 기대)도 픽스처에 맞춘다. Co-Authored-By: Claude Opus 5 (1M context) --- .../afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt | 2 -- .../feature/mindrecord/data/dto/MindRecordDtoContractTest.kt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 8ff8e85548..b92d97415e 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -801,7 +801,6 @@ private class CompletionDailyQuestionRepository( title = today.content, content = payload.content, createdAt = "2026-08-22", - imageUrl = payload.imageUrl, isDraft = payload.isDraft, ) today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) @@ -817,7 +816,6 @@ private class CompletionDailyQuestionRepository( if (answer.dailyQuestionId == id) { answer.copy( content = payload.content ?: answer.content, - imageUrl = payload.imageUrl ?: answer.imageUrl, isDraft = payload.isDraft ?: answer.isDraft, ) } else { 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 index 7e15c6c864..7320abda62 100644 --- 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 @@ -57,7 +57,7 @@ class MindRecordDtoContractTest { val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) // imageUrl 은 계약에 없어 DTO 에서 걷었다 — 썸네일은 본문 img 태그에서 뽑는다 (#549). - assertEquals("질문", decoded.data!!.single().title) + assertEquals("t", decoded.data!!.single().title) } // ---------- 수신자 목록 ----------