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/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 b379db34d8..8937002240 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,27 +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, - // 응답/요청 계약에 없는 필드라 타입은 nullable 로 두되 기본값은 두지 않는다. - // 매퍼가 항상 명시적으로 넘기므로 생략이 조용히 통과할 자리가 없다 (#789). - @SerialName("imageUrl") val imageUrl: String?, ) -/** - * `PATCH /daily-questions/{id}` 요청. 선택 필드라 nullable 은 유지하되 기본값은 두지 않는다 — - * 모든 생성 인자를 명시해야 "보내지 않음" 과 "명시적 null" 이 호출부 코드에서 구분된다 (#789). - */ +/** `PATCH /daily-questions/{id}` 요청. [DailyQuestionCreateRequestDto] 와 같은 이유로 `imageUrl` 이 없다. */ @Serializable data class DailyQuestionUpdateRequestDto( - @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?, + @SerialName("content") val content: String? = null, + @SerialName("isDraft") val isDraft: Boolean? = null, + @SerialName("date") val date: String? = null, + @SerialName("questionId") val questionId: Long? = null, ) @Serializable @@ -41,9 +40,9 @@ 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, + // `imageUrl` 은 응답 계약에 없고 실서버도 키 자체를 내려주지 않는다 — 항상 null 인 + // 필드를 읽느라 목록 카드 썸네일이 영영 뜨지 않았다. 썸네일은 표시 단계에서 + // `content` 의 첫 img 로 뽑는다 (#549). // Swagger `DailyQuestionListResponse` 및 실서버 응답 모두 와이어 키는 `isDraft`. // 과거 QA logcat 에서 `draft` 로 관측된 적이 있어 대체 키도 함께 허용한다. // 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/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 e12aea4f3c..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 @@ -56,7 +56,8 @@ class MindRecordDtoContractTest { val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body) - assertNull(decoded.data!!.single().imageUrl) + // imageUrl 은 계약에 없어 DTO 에서 걷었다 — 썸네일은 본문 img 태그에서 뽑는다 (#549). + assertEquals("t", decoded.data!!.single().title) } // ---------- 수신자 목록 ---------- @@ -140,7 +141,6 @@ class MindRecordDtoContractTest { isDraft = false, date = null, questionId = null, - imageUrl = null, ), ) 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/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index f79fe00f71..fa204f17f4 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). // 부분 성공에서 어떤 문구가 나가는지도 컴포지션을 태워야 확인된다 (#725). // 수신자 기록 본문 시트도 JVM 에서 실제로 렌더해 확인한다 (#618). + // 카드 미리보기의 대체 문자 제거도 HtmlCompat 파싱 결과라 실제 파서로 확인한다 (#549). testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) 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 8d77a3812c..7be02e373d 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 @@ -5,6 +5,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 @@ -38,7 +39,8 @@ fun DailyQuestionDomain.toUi(): DailyQuestion? { title = title, date = resolvedDate, 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 1d318b361a..0549131718 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,8 +13,62 @@ fun String.htmlToPlainText(): String = HtmlCompat .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) .toString() + // ImageGetter 없이 파싱하면 img 자리에 U+FFFC 가 남는다. 본문에 img 를 정식으로 + // 넣기 시작하면(#549) 카드 미리보기 둘째 줄이 통째로 `` 가 된다 — 공백이 아니라 + // trim() 으로는 안 지워진다. + .replace("\uFFFC", "") .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/...`)을 코드에 + * 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다. + * + * 다만 이 방식은 `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정한다. **권위 있는 + * 출처는 presigned 응답의 `fileKey` 인데 `PhotoUploadRepository.upload()` 가 `fileUrl` 만 + * 돌려주며 버린다** — CDN 이 경로 프리픽스나 쿼리스트링을 붙이면 조용히 틀린 키가 된다. + * 반환을 넓히는 것은 `core:data` 범위라 별건으로 둔다 (#1017). + */ +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/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 55c2580fc2..297d282cb3 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 @@ -17,7 +17,6 @@ data class DailyQuestionWriteUiState( */ val draftLoaded: Boolean = false, val answer: String = "", - val imageUrl: String? = null, val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, /** 이어쓸 임시저장 본문을 불러오는 중 (#923). */ 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 c9f950b5ba..7d95b43c4f 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 @@ -10,6 +10,7 @@ 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.isHtmlBlank +import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -110,7 +111,6 @@ class DailyQuestionWriteViewModel it.copy( draftId = draft.dailyQuestionId, answer = if (appliesDraft) draft.content else it.answer, - imageUrl = it.imageUrl ?: draft.imageUrl, isResumingDraft = false, // 화면이 이 전환을 보고 에디터를 재생성해 본문을 다시 싣는다 (#923). // **다시 실을 값이 있을 때만** 뒤집는다 — 사용자가 이미 쓴 내용을 보존한 @@ -121,20 +121,39 @@ class DailyQuestionWriteViewModel } } + /** 이번 작성 중 업로드한 이미지의 원본 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`). @@ -175,19 +194,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, ), ) } @@ -205,6 +222,10 @@ class DailyQuestionWriteViewModel } // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). if (isDraft) loadDraftCount() + // 제출이 성공하면 staging 키는 이미 permanent 로 옮겨졌다. 남겨 두면 + // 같은 ViewModel 로 두 번 제출될 때(예: 임시저장 뒤 화면에 머무는 흐름) + // 이미 옮겨진 파일을 다시 옮기려다 실패한다 (#549). + uploadedImageUrls.clear() }.onFailure { e -> _uiState.update { it.copy( diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt index e5f8f5d6b6..08770f2007 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt @@ -58,6 +58,5 @@ class DailyQuestionDateFallbackTest { content = "답변", createdAt = createdAt, isDraft = false, - imageUrl = null, ) } 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 67aec8d37d..a85fb699d7 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). @@ -60,4 +61,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..1b3a17d1cd --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlImageSrcTest.kt @@ -0,0 +1,128 @@ +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). + * + * 데일리질문의 요청/응답 계약에 `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()) + } + + @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 c77902d7ad..0c848b6be8 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 @@ -95,9 +95,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( @@ -110,22 +108,46 @@ 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 `화면에 이미지가 없으면 draft 이미지를 이어받는다`() { + 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") }, + noopDraftLoader(), + ) + + val url = runBlocking { viewModel.uploadImage("content://picked") } + + assertEquals("https://cdn/picked.jpg", url) + } + + @Test + fun `이어쓰기는 본문에 실린 이미지를 그대로 가져온다`() { + // 이미지가 본문 안에 있으므로 이어쓰기가 본문만 복원하면 이미지도 따라온다. + // 여기서 고정하는 것은 ViewModel 단까지다 — 화면은 에디터가 외부 value 를 첫 + // 컴포지션 1회만 시드해서 아직 성립하지 않는다 (#923 소관). + 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, noopDraftLoader()) - assertEquals("https://cdn/old.jpg", viewModel.uiState.value.imageUrl) + assertEquals(html, viewModel.uiState.value.answer) } @Test @@ -153,23 +175,72 @@ 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") + }, + noopDraftLoader(), + ) + + 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, noopDraftLoader()) + + 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 @@ -183,8 +254,8 @@ private class FakeDailyQuestionRepository( override suspend fun create(payload: DailyQuestionCreatePayload): Result { createCallCount += 1 - // 서버가 돌려주는 "내 답변" 식별자 (#573). - return Result.success(CREATED_ANSWER_ID) + // 서버가 돌려주는 "내 답변" 식별자 (#573). 주입된 동작이 실패를 흉내 내면 그것을 쓴다. + return onCreate(payload).map { CREATED_ANSWER_ID } } override suspend fun update( diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index 7f66c2bb0e..e047f4990f 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -118,7 +118,6 @@ class DraftListDeleteTest { content = "임시저장 본문", createdAt = "2026-08-23T10:00:00", isDraft = true, - imageUrl = null, ) }