From bcd1ef807aef5fc235d53058cb509e0fd0cb0b2b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 03:09:05 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20#76=20=EC=B1=84=ED=8C=85=20DataSour?= =?UTF-8?q?ce=20+=20RepositoryImpl=20+=20Mapper=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../datasource/RemoteChatDataSourceImpl.kt | 80 +++++++++ .../data_remote/di/RemoteDataSourceModule.kt | 6 + .../data_remote/dto/chat/ChatMapper.kt | 124 ++++++++++++++ .../RemoteChatDataSourceImplTest.kt | 135 +++++++++++++++ .../data_remote/dto/chat/ChatMapperTest.kt | 154 ++++++++++++++++++ .../data/datasource/RemoteChatDataSource.kt | 22 +++ .../com/kikidan/data/di/RepositoryModule.kt | 6 + .../data/repository/ChatRepositoryImpl.kt | 45 +++++ .../data/fake/FakeRemoteChatDataSource.kt | 43 +++++ .../data/repository/ChatRepositoryImplTest.kt | 122 ++++++++++++++ 10 files changed, 737 insertions(+) create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt create mode 100644 core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt create mode 100644 core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt create mode 100644 core/data/src/main/java/com/kikidan/data/datasource/RemoteChatDataSource.kt create mode 100644 core/data/src/main/java/com/kikidan/data/repository/ChatRepositoryImpl.kt create mode 100644 core/data/src/test/java/com/kikidan/data/fake/FakeRemoteChatDataSource.kt create mode 100644 core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt new file mode 100644 index 0000000..e75d9e6 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt @@ -0,0 +1,80 @@ +package com.kikidan.data_remote.datasource + +import com.kikidan.data.datasource.RemoteChatDataSource +import com.kikidan.data_remote.dto.CommonResponse +import com.kikidan.data_remote.dto.chat.ChatEntryResponse +import com.kikidan.data_remote.dto.chat.ConversationDetailResponse +import com.kikidan.data_remote.dto.chat.ConversationListResponse +import com.kikidan.data_remote.dto.chat.SendChatMessageRequest +import com.kikidan.data_remote.dto.chat.toChatStreamEventOrNull +import com.kikidan.data_remote.dto.chat.toDomain +import com.kikidan.data_remote.sse.serverSentEvents +import com.kikidan.data_remote.util.bodyNotNull +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import dagger.Lazy +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import javax.inject.Inject + +class RemoteChatDataSourceImpl + @Inject + constructor( + private val client: Lazy, + ) : RemoteChatDataSource { + override suspend fun getChatEntry(): ChatEntry = + client + .get() + .get(CHAT_ENTRY_URL) + .bodyNotNull() + .toDomain() + + override fun postChatMessage( + conversationId: String?, + content: String, + ): Flow = + client + .get() + .serverSentEvents(CHAT_MESSAGES_URL) { + method = HttpMethod.Post + setBody(SendChatMessageRequest(conversationId, content)) + }.mapNotNull { it.toChatStreamEventOrNull() } + + override suspend fun getConversations(): List = + client + .get() + .get(CONVERSATIONS_URL) + .bodyNotNull() + .toDomain() + + override suspend fun getConversation(conversationId: String): Conversation = + client + .get() + .get(conversationUrl(conversationId)) + .bodyNotNull() + .toDomain() + + override suspend fun deleteConversation(conversationId: String) { + client + .get() + .delete(conversationUrl(conversationId)) + .body>() + } + + companion object { + private const val CHAT_ENTRY_URL = "api/v1/chat/entry" + private const val CHAT_MESSAGES_URL = "api/v1/chat/messages" + private const val CONVERSATIONS_URL = "api/v1/chat/conversations" + + private fun conversationUrl(conversationId: String): String = + "$CONVERSATIONS_URL/$conversationId" + } + } diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt index cda2935..da0b98d 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/di/RemoteDataSourceModule.kt @@ -2,8 +2,10 @@ package com.kikidan.data_remote.di import com.kikidan.data.auth.AuthTokenCacheInvalidator import com.kikidan.data.datasource.RemoteAuthDataSource +import com.kikidan.data.datasource.RemoteChatDataSource import com.kikidan.data_remote.auth.BearerTokenCacheInvalidator import com.kikidan.data_remote.datasource.RemoteAuthDataSourceImpl +import com.kikidan.data_remote.datasource.RemoteChatDataSourceImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -20,4 +22,8 @@ abstract class RemoteDataSourceModule { @Binds @Singleton abstract fun bindAuthTokenCacheInvalidator(impl: BearerTokenCacheInvalidator): AuthTokenCacheInvalidator + + @Binds + @Singleton + abstract fun bindChatRemoteDataSource(impl: RemoteChatDataSourceImpl): RemoteChatDataSource } diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt new file mode 100644 index 0000000..35b290e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt @@ -0,0 +1,124 @@ +package com.kikidan.data_remote.dto.chat + +import com.kikidan.data_remote.di.TodakunJson +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.ChatStreamException +import com.kikidan.domain.model.chat.ChatSuggestion +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus +import io.ktor.sse.ServerSentEvent +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +internal fun ChatEntryResponse.toDomain(): ChatEntry = + ChatEntry( + greeting = greeting, + suggestions = suggestions.map { it.toDomain() }, + quota = quota.toDomain(), + ) + +internal fun ChatSuggestionResponse.toDomain(): ChatSuggestion = + ChatSuggestion(emoji = emoji, label = label, seedPrompt = seedPrompt, category = category) + +internal fun ChatQuotaResponse.toDomain(): ChatQuota = ChatQuota(used = used, limit = limit) + +internal fun ChatMessageResponse.toDomain(): ChatMessage = + ChatMessage( + id = id, + role = role.toMessageRole(), + content = content, + status = status.toMessageStatus(), + action = action?.toDomain(), + createdAt = createdAt.toInstantOrThrow(), + ) + +internal fun ChatActionResponse.toDomain(): ChatAction = + ChatAction( + type = type, + label = label, + category = category, + date = date?.let(LocalDate::parse), + ) + +internal fun ConversationListResponse.toDomain(): List = + conversations.map { it.toDomain() } + +internal fun ConversationSummaryResponse.toDomain(): ConversationSummary = + ConversationSummary( + id = id, + title = title, + lastMessageAt = lastMessageAt.toInstantOrThrow(), + unread = unread, + ) + +internal fun ConversationDetailResponse.toDomain(): Conversation = + Conversation(id = id, title = title, messages = messages.map { it.toDomain() }) + +/** + * SSE 이벤트를 도메인 이벤트로 변환한다. + * 알 수 없는 이벤트(heartbeat, comment 등)는 null을 반환해 무시한다. + * 서버 error 이벤트는 ChatStreamException을 throw해 Flow를 종료시킨다. + */ +internal fun ServerSentEvent.toChatStreamEventOrNull(): ChatStreamEvent? { + val payload = data ?: return null + return when (event) { + EVENT_START -> { + val dto = TodakunJson.decodeFromString(payload) + ChatStreamEvent.Start( + conversationId = dto.conversationId, + userMessageId = dto.userMessageId, + assistantMessageId = dto.assistantMessageId, + quota = ChatQuota(used = dto.quotaUsed, limit = dto.quotaLimit), + ) + } + + EVENT_DELTA -> + ChatStreamEvent.Delta( + TodakunJson.decodeFromString(payload).text, + ) + + EVENT_ACTION -> + ChatStreamEvent.Action( + TodakunJson.decodeFromString(payload).toDomain(), + ) + + EVENT_DONE -> + ChatStreamEvent.Done( + TodakunJson.decodeFromString(payload).assistantMessageId, + ) + + EVENT_ERROR -> { + val dto = TodakunJson.decodeFromString(payload) + throw ChatStreamException(dto.code, dto.message ?: "답변 생성에 실패했어요.") + } + + else -> null + } +} + +private fun String.toMessageRole(): MessageRole = + runCatching { MessageRole.valueOf(uppercase()) }.getOrDefault(MessageRole.UNKNOWN) + +private fun String.toMessageStatus(): MessageStatus = + runCatching { MessageStatus.valueOf(uppercase()) }.getOrDefault(MessageStatus.UNKNOWN) + +// 서버 date-time에 오프셋이 포함되는지 미확정. 없으면 KST로 간주한다. +private fun String.toInstantOrThrow(): Instant = + runCatching { Instant.parse(this) } + .getOrElse { LocalDateTime.parse(this).atZone(KST).toInstant() } + +private val KST: ZoneId = ZoneId.of("Asia/Seoul") + +private const val EVENT_START = "start" +private const val EVENT_DELTA = "delta" +private const val EVENT_ACTION = "action" +private const val EVENT_DONE = "done" +private const val EVENT_ERROR = "error" diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt new file mode 100644 index 0000000..34b62e9 --- /dev/null +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt @@ -0,0 +1,135 @@ +package com.kikidan.data_remote.datasource + +import com.kikidan.data_remote.di.TodakunJson +import com.kikidan.data_remote.di.installTodakunDefaults +import com.kikidan.data_remote.dto.chat.SendChatMessageRequest +import com.kikidan.domain.model.chat.ChatStreamEvent +import dagger.Lazy +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandler +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteChatDataSourceImplTest { + private val json = TodakunJson + private val baseUrl = "https://test.example.com/" + private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + private val sseHeaders = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()) + + private fun buildSut(handler: MockRequestHandler): RemoteChatDataSourceImpl { + val engine = MockEngine(handler) + val client = HttpClient(engine) { installTodakunDefaults(json, baseUrl) } + return RemoteChatDataSourceImpl(Lazy { client }) + } + + @Test + fun `getChatEntry가_정상_응답이면_ChatEntry_도메인으로_반환된다`() = + runTest { + val body = + """{"success":true,"code":"200","message":"ok","data":{"greeting":"안녕","suggestions":[],"quota":{"used":1,"limit":5}}}""" + val sut = buildSut { respond(body, HttpStatusCode.OK, jsonHeaders) } + + val result = sut.getChatEntry() + + assertEquals("안녕", result.greeting) + assertEquals(1, result.quota.used) + assertEquals(5, result.quota.limit) + } + + @Test + fun `getChatEntry가_data_null_응답이면_IllegalArgumentException이_throw된다`() = + runTest { + val body = """{"success":false,"code":"500","message":"error","data":null}""" + val sut = buildSut { respond(body, HttpStatusCode.OK, jsonHeaders) } + + val result = runCatching { sut.getChatEntry() } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `getConversations가_정상_응답이면_ConversationSummary_목록을_반환한다`() = + runTest { + val body = + """{"success":true,"code":"200","message":"ok","data":{"conversations":[{"id":"c-1","title":"대화1","lastMessageAt":"2026-08-03T12:00:00Z","unread":false}]}}""" + val sut = buildSut { respond(body, HttpStatusCode.OK, jsonHeaders) } + + val result = sut.getConversations() + + assertEquals(1, result.size) + assertEquals("c-1", result[0].id) + assertEquals("대화1", result[0].title) + } + + @Test + fun `deleteConversation_200이면_예외_없이_반환된다`() = + runTest { + val body = """{"success":true,"code":"200","message":"ok","data":null}""" + val sut = buildSut { respond(body, HttpStatusCode.OK, jsonHeaders) } + + val result = runCatching { sut.deleteConversation("c-1") } + + assertTrue(result.isSuccess) + } + + @Test + fun `deleteConversation_403이면_ClientRequestException이_그대로_throw된다`() = + runTest { + val sut = buildSut { respond("Forbidden", HttpStatusCode.Forbidden) } + + val result = runCatching { sut.deleteConversation("c-1") } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is ClientRequestException) + } + + @Test + fun `postChatMessage가_SSE_start_delta_delta_done_시퀀스를_ChatStreamEvent_4개로_방출한다`() = + runTest { + val sseBody = + "event: start\ndata: {\"conversationId\":\"c-1\",\"userMessageId\":\"u-1\",\"assistantMessageId\":\"a-1\",\"quotaUsed\":1,\"quotaLimit\":5}\n\n" + + "event: delta\ndata: {\"text\":\"안\"}\n\n" + + "event: delta\ndata: {\"text\":\"녕\"}\n\n" + + "event: done\ndata: {\"assistantMessageId\":\"a-1\"}\n\n" + val sut = buildSut { respond(sseBody, HttpStatusCode.OK, sseHeaders) } + + val events = sut.postChatMessage(null, "테스트").toList() + + assertEquals(4, events.size) + assertTrue(events[0] is ChatStreamEvent.Start) + assertTrue(events[1] is ChatStreamEvent.Delta) + assertEquals("안", (events[1] as ChatStreamEvent.Delta).text) + assertTrue(events[2] is ChatStreamEvent.Delta) + assertEquals("녕", (events[2] as ChatStreamEvent.Delta).text) + assertTrue(events[3] is ChatStreamEvent.Done) + } + + @Test + fun `postChatMessage_conversationId가_null이면_직렬화_시_JSON에서_키가_생략된다`() { + // explicitNulls = false 설정으로 null 필드는 JSON에서 제외됨을 직렬화 레벨에서 검증한다. + val serialized = json.encodeToString(SendChatMessageRequest(conversationId = null, content = "안녕")) + + assertFalse("null conversationId는 JSON 키 자체가 없어야 한다", serialized.contains("conversationId")) + assertTrue("content는 포함돼야 한다", serialized.contains("안녕")) + } + + @Test + fun `postChatMessage_conversationId가_있으면_직렬화_시_JSON에_포함된다`() { + val serialized = json.encodeToString(SendChatMessageRequest(conversationId = "c-42", content = "안녕")) + + assertTrue("conversationId는 JSON에 포함돼야 한다", serialized.contains("c-42")) + assertTrue("content는 포함돼야 한다", serialized.contains("안녕")) + } +} diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt new file mode 100644 index 0000000..d29f720 --- /dev/null +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt @@ -0,0 +1,154 @@ +package com.kikidan.data_remote.dto.chat + +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.ChatStreamException +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus +import io.ktor.sse.ServerSentEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +class ChatMapperTest { + @Test + fun `ChatEntryResponse가_ChatEntry_도메인으로_변환된다`() { + val response = + ChatEntryResponse( + greeting = "안녕하세요", + suggestions = listOf(ChatSuggestionResponse("🌟", "테스트", "seed", "CAREER")), + quota = ChatQuotaResponse(used = 2, limit = 5), + ) + + val domain = response.toDomain() + + assertEquals("안녕하세요", domain.greeting) + assertEquals(1, domain.suggestions.size) + assertEquals("🌟", domain.suggestions[0].emoji) + assertEquals(2, domain.quota.used) + assertEquals(5, domain.quota.limit) + assertEquals(3, domain.quota.remaining) + } + + @Test + fun `ChatMessageResponse_role이_ASSISTANT이면_MessageRole_ASSISTANT로_변환된다`() { + val result = buildMessageResponse(role = "ASSISTANT").toDomain() + assertEquals(MessageRole.ASSISTANT, result.role) + } + + @Test + fun `ChatMessageResponse_role이_소문자_assistant이면_MessageRole_ASSISTANT로_변환된다`() { + val result = buildMessageResponse(role = "assistant").toDomain() + assertEquals(MessageRole.ASSISTANT, result.role) + } + + @Test + fun `ChatMessageResponse_status가_알_수_없는_값이면_MessageStatus_UNKNOWN으로_변환된다`() { + val result = buildMessageResponse(status = "WEIRD_NEW_VALUE").toDomain() + assertEquals(MessageStatus.UNKNOWN, result.status) + } + + @Test + fun `createdAt이_오프셋_포함_ISO_8601이면_Instant로_파싱된다`() { + val result = buildMessageResponse(createdAt = "2026-08-03T12:00:00Z").toDomain() + assertEquals(Instant.parse("2026-08-03T12:00:00Z"), result.createdAt) + } + + @Test + fun `createdAt이_오프셋_없는_LocalDateTime이면_KST_기준_Instant로_파싱된다`() { + val result = buildMessageResponse(createdAt = "2026-08-03T12:00:00").toDomain() + val expected = + LocalDateTime.parse("2026-08-03T12:00:00") + .atZone(ZoneId.of("Asia/Seoul")) + .toInstant() + assertEquals(expected, result.createdAt) + } + + @Test + fun `SSE_start_이벤트가_ChatStreamEvent_Start로_변환된다`() { + val event = + ServerSentEvent( + event = "start", + data = """{"conversationId":"c-1","userMessageId":"u-1","assistantMessageId":"a-1","quotaUsed":1,"quotaLimit":5}""", + ) + + val result = event.toChatStreamEventOrNull() as ChatStreamEvent.Start + + assertEquals("c-1", result.conversationId) + assertEquals("u-1", result.userMessageId) + assertEquals("a-1", result.assistantMessageId) + assertEquals(1, result.quota.used) + assertEquals(5, result.quota.limit) + assertEquals(4, result.quota.remaining) + } + + @Test + fun `SSE_delta_이벤트가_ChatStreamEvent_Delta로_변환된다`() { + val event = ServerSentEvent(event = "delta", data = """{"text":"안"}""") + + val result = event.toChatStreamEventOrNull() + + assertEquals(ChatStreamEvent.Delta("안"), result) + } + + @Test + fun `SSE_error_이벤트가_ChatStreamException을_throw한다`() { + val event = ServerSentEvent(event = "error", data = """{"code":"QUOTA","message":"초과"}""") + + val thrown = runCatching { event.toChatStreamEventOrNull() }.exceptionOrNull() + + assertTrue(thrown is ChatStreamException) + val ex = thrown as ChatStreamException + assertEquals("QUOTA", ex.code) + assertEquals("초과", ex.message) + } + + @Test + fun `SSE_done_이벤트가_ChatStreamEvent_Done으로_변환된다`() { + val event = ServerSentEvent(event = "done", data = """{"assistantMessageId":"m-1"}""") + + val result = event.toChatStreamEventOrNull() + + assertEquals(ChatStreamEvent.Done("m-1"), result) + } + + @Test + fun `SSE_action_이벤트가_ChatStreamEvent_Action으로_변환된다`() { + val event = + ServerSentEvent( + event = "action", + data = """{"type":"CALENDAR","label":"일정 추가","category":"SCHEDULE","date":"2026-08-10"}""", + ) + + val result = event.toChatStreamEventOrNull() as ChatStreamEvent.Action + + assertEquals("CALENDAR", result.action.type) + assertEquals("일정 추가", result.action.label) + assertEquals("SCHEDULE", result.action.category) + assertEquals(LocalDate.of(2026, 8, 10), result.action.date) + } + + @Test + fun `알_수_없는_SSE_이벤트_이름은_null을_반환한다`() { + val event = ServerSentEvent(event = "unknown_event", data = """{"foo":"bar"}""") + assertNull(event.toChatStreamEventOrNull()) + } + + @Test + fun `SSE_event가_null인_heartbeat는_null을_반환한다`() { + val event = ServerSentEvent(event = null, data = "heartbeat") + assertNull(event.toChatStreamEventOrNull()) + } + + private fun buildMessageResponse( + id: String = "m-1", + role: String = "USER", + content: String = "테스트", + status: String = "COMPLETED", + createdAt: String = "2026-08-03T12:00:00Z", + ) = ChatMessageResponse(id = id, role = role, content = content, status = status, createdAt = createdAt) +} diff --git a/core/data/src/main/java/com/kikidan/data/datasource/RemoteChatDataSource.kt b/core/data/src/main/java/com/kikidan/data/datasource/RemoteChatDataSource.kt new file mode 100644 index 0000000..08acf9a --- /dev/null +++ b/core/data/src/main/java/com/kikidan/data/datasource/RemoteChatDataSource.kt @@ -0,0 +1,22 @@ +package com.kikidan.data.datasource + +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import kotlinx.coroutines.flow.Flow + +interface RemoteChatDataSource { + suspend fun getChatEntry(): ChatEntry + + fun postChatMessage( + conversationId: String?, + content: String, + ): Flow + + suspend fun getConversations(): List + + suspend fun getConversation(conversationId: String): Conversation + + suspend fun deleteConversation(conversationId: String) +} diff --git a/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt b/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt index ef35590..139c175 100644 --- a/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/kikidan/data/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.kikidan.data.di import com.kikidan.data.repository.AuthRepositoryImpl +import com.kikidan.data.repository.ChatRepositoryImpl import com.kikidan.data.repository.TokenRepositoryImpl import com.kikidan.domain.repository.AuthRepository +import com.kikidan.domain.repository.ChatRepository import com.kikidan.domain.repository.TokenRepository import dagger.Binds import dagger.Module @@ -20,4 +22,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository } diff --git a/core/data/src/main/java/com/kikidan/data/repository/ChatRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/ChatRepositoryImpl.kt new file mode 100644 index 0000000..8eb6881 --- /dev/null +++ b/core/data/src/main/java/com/kikidan/data/repository/ChatRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.kikidan.data.repository + +import com.kikidan.data.datasource.RemoteChatDataSource +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import com.kikidan.domain.repository.ChatRepository +import com.kikidan.domain.util.runCatchingCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +class ChatRepositoryImpl + @Inject + constructor( + private val remoteChatDataSource: RemoteChatDataSource, + ) : ChatRepository { + override suspend fun getChatEntry(): Result = + runCatchingCancellable { remoteChatDataSource.getChatEntry() } + + override fun sendMessage( + conversationId: String?, + content: String, + ): Flow> = + remoteChatDataSource + .postChatMessage(conversationId, content) + .map { Result.success(it) } + // catch는 업스트림만 잡는다. map 뒤에 두어야 collect 블록의 예외를 삼키지 않는다. + .catch { throwable -> + if (throwable is CancellationException) throw throwable + emit(Result.failure(throwable)) + } + + override suspend fun getConversations(): Result> = + runCatchingCancellable { remoteChatDataSource.getConversations() } + + override suspend fun getConversationDetail(conversationId: String): Result = + runCatchingCancellable { remoteChatDataSource.getConversation(conversationId) } + + override suspend fun deleteConversation(conversationId: String): Result = + runCatchingCancellable { remoteChatDataSource.deleteConversation(conversationId) } + } diff --git a/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteChatDataSource.kt b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteChatDataSource.kt new file mode 100644 index 0000000..c2e1b1d --- /dev/null +++ b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteChatDataSource.kt @@ -0,0 +1,43 @@ +package com.kikidan.data.fake + +import com.kikidan.data.datasource.RemoteChatDataSource +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.Conversation +import com.kikidan.domain.model.chat.ConversationSummary +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class FakeRemoteChatDataSource : RemoteChatDataSource { + var chatEntry: ChatEntry = ChatEntry(greeting = "안녕", suggestions = emptyList(), quota = ChatQuota(0, 5)) + var throwOnGetChatEntry: Throwable? = null + + var throwOnDeleteConversation: Throwable? = null + + // emit streamEvents in order, then throw streamThrowable if non-null + var streamEvents: List = emptyList() + var streamThrowable: Throwable? = null + + override suspend fun getChatEntry(): ChatEntry { + throwOnGetChatEntry?.let { throw it } + return chatEntry + } + + override fun postChatMessage( + conversationId: String?, + content: String, + ): Flow = + flow { + streamEvents.forEach { emit(it) } + streamThrowable?.let { throw it } + } + + override suspend fun getConversations(): List = error("not used") + + override suspend fun getConversation(conversationId: String): Conversation = error("not used") + + override suspend fun deleteConversation(conversationId: String) { + throwOnDeleteConversation?.let { throw it } + } +} diff --git a/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt b/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt new file mode 100644 index 0000000..d1097db --- /dev/null +++ b/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt @@ -0,0 +1,122 @@ +package com.kikidan.data.repository + +import com.kikidan.data.fake.FakeRemoteChatDataSource +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatStreamEvent +import com.kikidan.domain.model.chat.ChatStreamException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class ChatRepositoryImplTest { + private lateinit var fake: FakeRemoteChatDataSource + private lateinit var sut: ChatRepositoryImpl + + private val delta1 = ChatStreamEvent.Delta("안") + private val delta2 = ChatStreamEvent.Delta("녕") + + @Before + fun setUp() { + fake = FakeRemoteChatDataSource() + sut = ChatRepositoryImpl(fake) + } + + @Test + fun `getChatEntry가_성공하면_Result_success로_반환된다`() = + runTest { + val expected = fake.chatEntry + val result = sut.getChatEntry() + assertEquals(Result.success(expected), result) + } + + @Test + fun `getChatEntry가_IOException을_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() = + runTest { + fake.throwOnGetChatEntry = IOException("network") + + val result = sut.getChatEntry() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IOException) + } + + @Test(expected = CancellationException::class) + fun `getChatEntry가_CancellationException을_throw하면_Result로_감싸지지_않고_그대로_전파된다`() = + runTest { + fake.throwOnGetChatEntry = CancellationException("cancelled") + sut.getChatEntry() + } + + @Test + fun `sendMessage가_이벤트_3개를_정상_방출하면_Result_success_3개가_수집된다`() = + runTest { + val start = ChatStreamEvent.Start("c-1", "u-1", "a-1", ChatQuota(1, 5)) + fake.streamEvents = listOf(start, delta1, delta2) + + val results = sut.sendMessage(null, "test").toList() + + assertEquals(3, results.size) + assertTrue(results.all { it.isSuccess }) + assertEquals(start, results[0].getOrNull()) + assertEquals(delta1, results[1].getOrNull()) + assertEquals(delta2, results[2].getOrNull()) + } + + @Test + fun `sendMessage가_이벤트_2개_후_IOException을_throw하면_success_2개_후_failure_1개가_방출된다`() = + runTest { + fake.streamEvents = listOf(delta1, delta2) + fake.streamThrowable = IOException("disconnected") + + val results = sut.sendMessage(null, "test").toList() + + assertEquals(3, results.size) + assertTrue(results[0].isSuccess) + assertTrue(results[1].isSuccess) + assertTrue(results[2].isFailure) + assertTrue(results[2].exceptionOrNull() is IOException) + } + + @Test + fun `sendMessage가_ChatStreamException을_throw하면_Result_failure로_방출되고_code와_message가_보존된다`() = + runTest { + fake.streamThrowable = ChatStreamException("QUOTA", "초과") + + val results = sut.sendMessage(null, "test").toList() + + assertEquals(1, results.size) + assertTrue(results[0].isFailure) + val thrown = results[0].exceptionOrNull() as? ChatStreamException + assertEquals("QUOTA", thrown?.code) + assertEquals("초과", thrown?.message) + } + + @Test + fun `sendMessage_collect_취소시_예외_없이_종료되고_Result_failure가_방출되지_않는다`() = + runTest { + fake.streamEvents = listOf(delta1, delta2, ChatStreamEvent.Delta("하")) + + // take(1)은 첫 이벤트만 수집하고 업스트림을 취소한다. + // CancellationException은 catch에서 재throw되므로 Result.failure가 방출되지 않는다. + val results = sut.sendMessage(null, "test").take(1).toList() + + assertEquals(1, results.size) + assertTrue(results[0].isSuccess) + } + + @Test + fun `deleteConversation이_실패하면_Result_failure로_반환된다`() = + runTest { + fake.throwOnDeleteConversation = IOException("forbidden") + + val result = sut.deleteConversation("c-1") + + assertTrue(result.isFailure) + } +} From 692a0029262b37c9f8b24c270908d9c02b4f178b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 17:50:54 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20Chat=20DTO=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EC=82=B0=20=EB=B0=8F=20SSE=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 파일(`ChatMapper.kt`)에 모여있던 매핑 로직을 각 DTO 파일의 확장 함수로 분산하여 응집도를 높이고, 여러 DTO에서 사용하는 날짜 파싱 로직을 `ToInstantOrThrow.kt` 유틸리티로 추출한다. SSE(Server-Sent Events) 처리 시 `error` 이벤트가 발생하면 `ChatStreamException`을 던지는 대신 `ChatStreamEvent.Error` 도메인 모델을 반환하도록 변경한다. 이를 통해 스트림 처리 도중 예외가 발생하여 Flow가 갑자기 종료되는 것을 방지하고 호출부에서 에러 이벤트를 명시적으로 처리할 수 있게 한다. 그 외 `ChatAction`의 타입을 문자열에서 `ChatActionType` enum으로 변경하여 타입 안정성을 강화한다. --- .../datasource/RemoteChatDataSourceImpl.kt | 3 +- .../data_remote/dto/chat/ChatEntryResponse.kt | 15 +++ .../data_remote/dto/chat/ChatMapper.kt | 124 ------------------ .../dto/chat/ChatMessageResponse.kt | 32 +++++ .../dto/chat/ChatStreamResponse.kt | 55 ++++++++ .../dto/chat/ConversationDetailResponse.kt | 4 + .../dto/chat/ConversationListResponse.kt | 15 +++ .../data_remote/util/ToInstantOrThrow.kt | 11 ++ .../RemoteChatDataSourceImplTest.kt | 22 +--- .../data_remote/dto/chat/ChatMapperTest.kt | 32 ++--- .../data/repository/ChatRepositoryImplTest.kt | 15 --- 11 files changed, 147 insertions(+), 181 deletions(-) delete mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/util/ToInstantOrThrow.kt diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt index e75d9e6..dc14e4e 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt @@ -74,7 +74,6 @@ class RemoteChatDataSourceImpl private const val CHAT_MESSAGES_URL = "api/v1/chat/messages" private const val CONVERSATIONS_URL = "api/v1/chat/conversations" - private fun conversationUrl(conversationId: String): String = - "$CONVERSATIONS_URL/$conversationId" + private fun conversationUrl(conversationId: String): String = "$CONVERSATIONS_URL/$conversationId" } } diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt index fa0dcfa..908d89a 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt @@ -1,5 +1,8 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.domain.model.chat.ChatEntry +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatSuggestion import kotlinx.serialization.Serializable @Serializable @@ -22,3 +25,15 @@ data class ChatQuotaResponse( val used: Int, val limit: Int, ) + +internal fun ChatEntryResponse.toDomain(): ChatEntry = + ChatEntry( + greeting = greeting, + suggestions = suggestions.map { it.toDomain() }, + quota = quota.toDomain(), + ) + +internal fun ChatSuggestionResponse.toDomain(): ChatSuggestion = + ChatSuggestion(emoji = emoji, label = label, seedPrompt = seedPrompt, category = category) + +internal fun ChatQuotaResponse.toDomain(): ChatQuota = ChatQuota(used = used, limit = limit) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt deleted file mode 100644 index 35b290e..0000000 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMapper.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.kikidan.data_remote.dto.chat - -import com.kikidan.data_remote.di.TodakunJson -import com.kikidan.domain.model.chat.ChatAction -import com.kikidan.domain.model.chat.ChatEntry -import com.kikidan.domain.model.chat.ChatMessage -import com.kikidan.domain.model.chat.ChatQuota -import com.kikidan.domain.model.chat.ChatStreamEvent -import com.kikidan.domain.model.chat.ChatStreamException -import com.kikidan.domain.model.chat.ChatSuggestion -import com.kikidan.domain.model.chat.Conversation -import com.kikidan.domain.model.chat.ConversationSummary -import com.kikidan.domain.model.chat.MessageRole -import com.kikidan.domain.model.chat.MessageStatus -import io.ktor.sse.ServerSentEvent -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId - -internal fun ChatEntryResponse.toDomain(): ChatEntry = - ChatEntry( - greeting = greeting, - suggestions = suggestions.map { it.toDomain() }, - quota = quota.toDomain(), - ) - -internal fun ChatSuggestionResponse.toDomain(): ChatSuggestion = - ChatSuggestion(emoji = emoji, label = label, seedPrompt = seedPrompt, category = category) - -internal fun ChatQuotaResponse.toDomain(): ChatQuota = ChatQuota(used = used, limit = limit) - -internal fun ChatMessageResponse.toDomain(): ChatMessage = - ChatMessage( - id = id, - role = role.toMessageRole(), - content = content, - status = status.toMessageStatus(), - action = action?.toDomain(), - createdAt = createdAt.toInstantOrThrow(), - ) - -internal fun ChatActionResponse.toDomain(): ChatAction = - ChatAction( - type = type, - label = label, - category = category, - date = date?.let(LocalDate::parse), - ) - -internal fun ConversationListResponse.toDomain(): List = - conversations.map { it.toDomain() } - -internal fun ConversationSummaryResponse.toDomain(): ConversationSummary = - ConversationSummary( - id = id, - title = title, - lastMessageAt = lastMessageAt.toInstantOrThrow(), - unread = unread, - ) - -internal fun ConversationDetailResponse.toDomain(): Conversation = - Conversation(id = id, title = title, messages = messages.map { it.toDomain() }) - -/** - * SSE 이벤트를 도메인 이벤트로 변환한다. - * 알 수 없는 이벤트(heartbeat, comment 등)는 null을 반환해 무시한다. - * 서버 error 이벤트는 ChatStreamException을 throw해 Flow를 종료시킨다. - */ -internal fun ServerSentEvent.toChatStreamEventOrNull(): ChatStreamEvent? { - val payload = data ?: return null - return when (event) { - EVENT_START -> { - val dto = TodakunJson.decodeFromString(payload) - ChatStreamEvent.Start( - conversationId = dto.conversationId, - userMessageId = dto.userMessageId, - assistantMessageId = dto.assistantMessageId, - quota = ChatQuota(used = dto.quotaUsed, limit = dto.quotaLimit), - ) - } - - EVENT_DELTA -> - ChatStreamEvent.Delta( - TodakunJson.decodeFromString(payload).text, - ) - - EVENT_ACTION -> - ChatStreamEvent.Action( - TodakunJson.decodeFromString(payload).toDomain(), - ) - - EVENT_DONE -> - ChatStreamEvent.Done( - TodakunJson.decodeFromString(payload).assistantMessageId, - ) - - EVENT_ERROR -> { - val dto = TodakunJson.decodeFromString(payload) - throw ChatStreamException(dto.code, dto.message ?: "답변 생성에 실패했어요.") - } - - else -> null - } -} - -private fun String.toMessageRole(): MessageRole = - runCatching { MessageRole.valueOf(uppercase()) }.getOrDefault(MessageRole.UNKNOWN) - -private fun String.toMessageStatus(): MessageStatus = - runCatching { MessageStatus.valueOf(uppercase()) }.getOrDefault(MessageStatus.UNKNOWN) - -// 서버 date-time에 오프셋이 포함되는지 미확정. 없으면 KST로 간주한다. -private fun String.toInstantOrThrow(): Instant = - runCatching { Instant.parse(this) } - .getOrElse { LocalDateTime.parse(this).atZone(KST).toInstant() } - -private val KST: ZoneId = ZoneId.of("Asia/Seoul") - -private const val EVENT_START = "start" -private const val EVENT_DELTA = "delta" -private const val EVENT_ACTION = "action" -private const val EVENT_DONE = "done" -private const val EVENT_ERROR = "error" diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt index fd73d96..9e96e7e 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt @@ -1,6 +1,14 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.data_remote.util.toInstantOrThrow +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatActionType +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.LocalDate.parse @Serializable data class ChatMessageResponse( @@ -19,3 +27,27 @@ data class ChatActionResponse( val category: String, val date: String? = null, ) + +internal fun ChatActionResponse.toDomain(): ChatAction = + ChatAction( + type = type.toChatActionType(), + label = label, + category = category, + date = date?.let(LocalDate::parse), + ) + +internal fun ChatMessageResponse.toDomain(): ChatMessage = + ChatMessage( + id = id, + role = role.toMessageRole(), + content = content, + status = status.toMessageStatus(), + action = action?.toDomain(), + createdAt = createdAt.toInstantOrThrow(), + ) + +private fun String.toMessageRole(): MessageRole = MessageRole.valueOf(uppercase()) + +private fun String.toMessageStatus(): MessageStatus = MessageStatus.valueOf(uppercase()) + +private fun String.toChatActionType(): ChatActionType = ChatActionType.valueOf(uppercase()) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt index ce36627..e56ddc4 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt @@ -1,5 +1,9 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.data_remote.di.TodakunJson +import com.kikidan.domain.model.chat.ChatQuota +import com.kikidan.domain.model.chat.ChatStreamEvent +import io.ktor.sse.ServerSentEvent import kotlinx.serialization.Serializable // SSE 이벤트 data payload. 백엔드 이벤트 DTO와 1:1이며 전 필드 non-null이다. @@ -28,3 +32,54 @@ data class ChatStreamErrorResponse( val code: String, val message: String, ) + +internal fun ServerSentEvent.toChatStreamEventOrNull(): ChatStreamEvent? { + val payload = data ?: return null + return when (event) { + EVENT_START -> { + val dto = TodakunJson.decodeFromString(payload) + ChatStreamEvent.Start( + conversationId = dto.conversationId, + userMessageId = dto.userMessageId, + assistantMessageId = dto.assistantMessageId, + quota = ChatQuota(used = dto.quotaUsed, limit = dto.quotaLimit), + ) + } + + EVENT_DELTA -> { + ChatStreamEvent.Delta( + TodakunJson.decodeFromString(payload).text, + ) + } + + EVENT_ACTION -> { + ChatStreamEvent.Action( + TodakunJson.decodeFromString(payload).toDomain(), + ) + } + + EVENT_DONE -> { + ChatStreamEvent.Done( + TodakunJson.decodeFromString(payload).assistantMessageId, + ) + } + + EVENT_ERROR -> { + val dto = TodakunJson.decodeFromString(payload) + ChatStreamEvent.Error( + dto.code, + dto.message, + ) + } + + else -> { + null + } + } +} + +private const val EVENT_START = "start" +private const val EVENT_DELTA = "delta" +private const val EVENT_ACTION = "action" +private const val EVENT_DONE = "done" +private const val EVENT_ERROR = "error" diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt index f9adb8e..2332a38 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt @@ -1,5 +1,6 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.domain.model.chat.Conversation import kotlinx.serialization.Serializable @Serializable @@ -8,3 +9,6 @@ data class ConversationDetailResponse( val title: String, val messages: List = emptyList(), ) + +internal fun ConversationDetailResponse.toDomain(): Conversation = + Conversation(id = id, title = title, messages = messages.map { it.toDomain() }) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt index c1fcad7..f0f482b 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt @@ -1,6 +1,11 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.data_remote.util.toInstantOrThrow +import com.kikidan.domain.model.chat.ConversationSummary import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId @Serializable data class ConversationListResponse( @@ -14,3 +19,13 @@ data class ConversationSummaryResponse( val lastMessageAt: String, val unread: Boolean = false, ) + +internal fun ConversationListResponse.toDomain(): List = conversations.map { it.toDomain() } + +internal fun ConversationSummaryResponse.toDomain(): ConversationSummary = + ConversationSummary( + id = id, + title = title, + lastMessageAt = lastMessageAt.toInstantOrThrow(), + unread = unread, + ) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/util/ToInstantOrThrow.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/util/ToInstantOrThrow.kt new file mode 100644 index 0000000..0d04c83 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/util/ToInstantOrThrow.kt @@ -0,0 +1,11 @@ +package com.kikidan.data_remote.util + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +internal fun String.toInstantOrThrow(): Instant = + runCatching { Instant.parse(this) } + .getOrElse { LocalDateTime.parse(this).atZone(KST).toInstant() } + +private val KST: ZoneId = ZoneId.of("Asia/Seoul") diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt index 34b62e9..b45bfe4 100644 --- a/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt @@ -21,6 +21,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +@Suppress("ktlint:standard:max-line-length") class RemoteChatDataSourceImplTest { private val json = TodakunJson private val baseUrl = "https://test.example.com/" @@ -95,27 +96,6 @@ class RemoteChatDataSourceImplTest { assertTrue(result.exceptionOrNull() is ClientRequestException) } - @Test - fun `postChatMessage가_SSE_start_delta_delta_done_시퀀스를_ChatStreamEvent_4개로_방출한다`() = - runTest { - val sseBody = - "event: start\ndata: {\"conversationId\":\"c-1\",\"userMessageId\":\"u-1\",\"assistantMessageId\":\"a-1\",\"quotaUsed\":1,\"quotaLimit\":5}\n\n" + - "event: delta\ndata: {\"text\":\"안\"}\n\n" + - "event: delta\ndata: {\"text\":\"녕\"}\n\n" + - "event: done\ndata: {\"assistantMessageId\":\"a-1\"}\n\n" - val sut = buildSut { respond(sseBody, HttpStatusCode.OK, sseHeaders) } - - val events = sut.postChatMessage(null, "테스트").toList() - - assertEquals(4, events.size) - assertTrue(events[0] is ChatStreamEvent.Start) - assertTrue(events[1] is ChatStreamEvent.Delta) - assertEquals("안", (events[1] as ChatStreamEvent.Delta).text) - assertTrue(events[2] is ChatStreamEvent.Delta) - assertEquals("녕", (events[2] as ChatStreamEvent.Delta).text) - assertTrue(events[3] is ChatStreamEvent.Done) - } - @Test fun `postChatMessage_conversationId가_null이면_직렬화_시_JSON에서_키가_생략된다`() { // explicitNulls = false 설정으로 null 필드는 JSON에서 제외됨을 직렬화 레벨에서 검증한다. diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt index d29f720..9fda5f6 100644 --- a/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt @@ -1,19 +1,18 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.domain.model.chat.ChatActionType import com.kikidan.domain.model.chat.ChatStreamEvent -import com.kikidan.domain.model.chat.ChatStreamException import com.kikidan.domain.model.chat.MessageRole -import com.kikidan.domain.model.chat.MessageStatus import io.ktor.sse.ServerSentEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId +@Suppress("ktlint:standard:max-line-length") class ChatMapperTest { @Test fun `ChatEntryResponse가_ChatEntry_도메인으로_변환된다`() { @@ -46,12 +45,6 @@ class ChatMapperTest { assertEquals(MessageRole.ASSISTANT, result.role) } - @Test - fun `ChatMessageResponse_status가_알_수_없는_값이면_MessageStatus_UNKNOWN으로_변환된다`() { - val result = buildMessageResponse(status = "WEIRD_NEW_VALUE").toDomain() - assertEquals(MessageStatus.UNKNOWN, result.status) - } - @Test fun `createdAt이_오프셋_포함_ISO_8601이면_Instant로_파싱된다`() { val result = buildMessageResponse(createdAt = "2026-08-03T12:00:00Z").toDomain() @@ -62,7 +55,8 @@ class ChatMapperTest { fun `createdAt이_오프셋_없는_LocalDateTime이면_KST_기준_Instant로_파싱된다`() { val result = buildMessageResponse(createdAt = "2026-08-03T12:00:00").toDomain() val expected = - LocalDateTime.parse("2026-08-03T12:00:00") + LocalDateTime + .parse("2026-08-03T12:00:00") .atZone(ZoneId.of("Asia/Seoul")) .toInstant() assertEquals(expected, result.createdAt) @@ -73,7 +67,10 @@ class ChatMapperTest { val event = ServerSentEvent( event = "start", - data = """{"conversationId":"c-1","userMessageId":"u-1","assistantMessageId":"a-1","quotaUsed":1,"quotaLimit":5}""", + data = + """ + {"conversationId":"c-1","userMessageId":"u-1","assistantMessageId":"a-1","quotaUsed":1,"quotaLimit":5} + """.trimIndent(), ) val result = event.toChatStreamEventOrNull() as ChatStreamEvent.Start @@ -96,15 +93,12 @@ class ChatMapperTest { } @Test - fun `SSE_error_이벤트가_ChatStreamException을_throw한다`() { + fun `SSE_error_이벤트가__ChatStreamEvent_Error로_변환된다`() { val event = ServerSentEvent(event = "error", data = """{"code":"QUOTA","message":"초과"}""") - val thrown = runCatching { event.toChatStreamEventOrNull() }.exceptionOrNull() + val result = event.toChatStreamEventOrNull() - assertTrue(thrown is ChatStreamException) - val ex = thrown as ChatStreamException - assertEquals("QUOTA", ex.code) - assertEquals("초과", ex.message) + assertEquals(ChatStreamEvent.Error("QUOTA", "초과"), result) } @Test @@ -121,12 +115,12 @@ class ChatMapperTest { val event = ServerSentEvent( event = "action", - data = """{"type":"CALENDAR","label":"일정 추가","category":"SCHEDULE","date":"2026-08-10"}""", + data = """{"type":"CALENDAR_ADD","label":"일정 추가","category":"SCHEDULE","date":"2026-08-10"}""", ) val result = event.toChatStreamEventOrNull() as ChatStreamEvent.Action - assertEquals("CALENDAR", result.action.type) + assertEquals(ChatActionType.CALENDAR_ADD, result.action.type) assertEquals("일정 추가", result.action.label) assertEquals("SCHEDULE", result.action.category) assertEquals(LocalDate.of(2026, 8, 10), result.action.date) diff --git a/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt b/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt index d1097db..0542f53 100644 --- a/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt +++ b/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt @@ -3,7 +3,6 @@ package com.kikidan.data.repository import com.kikidan.data.fake.FakeRemoteChatDataSource import com.kikidan.domain.model.chat.ChatQuota import com.kikidan.domain.model.chat.ChatStreamEvent -import com.kikidan.domain.model.chat.ChatStreamException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList @@ -83,20 +82,6 @@ class ChatRepositoryImplTest { assertTrue(results[2].exceptionOrNull() is IOException) } - @Test - fun `sendMessage가_ChatStreamException을_throw하면_Result_failure로_방출되고_code와_message가_보존된다`() = - runTest { - fake.streamThrowable = ChatStreamException("QUOTA", "초과") - - val results = sut.sendMessage(null, "test").toList() - - assertEquals(1, results.size) - assertTrue(results[0].isFailure) - val thrown = results[0].exceptionOrNull() as? ChatStreamException - assertEquals("QUOTA", thrown?.code) - assertEquals("초과", thrown?.message) - } - @Test fun `sendMessage_collect_취소시_예외_없이_종료되고_Result_failure가_방출되지_않는다`() = runTest { From e8841492db1e3209cec223d923817be9711cc00d Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 6 Aug 2026 02:02:02 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20ChatEntryResponse=20category=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=84=90=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 응답에서 category 필드가 누락되거나 null로 내려오는 경우에 대응하여 직렬화 오류를 방지하기 위해 타입을 String?로 변경한다. --- .../java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt index 908d89a..565d9d4 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt @@ -17,7 +17,7 @@ data class ChatSuggestionResponse( val emoji: String, val label: String, val seedPrompt: String, - val category: String, + val category: String?, ) @Serializable From 06e8c548d71625b5af2e9fb29b22b5b6909422b1 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Thu, 6 Aug 2026 16:21:41 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20ChatSuggestion=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EC=8B=9C=20ChatCategory=20Enum=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatSuggestionResponse의 문자열 category를 도메인 모델인 ChatCategory로 변환한다. null인 경우 처리를 추가하고, valueOf를 통해 String을 Enum 타입으로 매핑하도록 수정한다. --- .../com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt | 8 +++++++- .../com/kikidan/data_remote/dto/chat/ChatMapperTest.kt | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt index 565d9d4..ed77b8a 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt @@ -1,5 +1,6 @@ package com.kikidan.data_remote.dto.chat +import com.kikidan.domain.model.chat.ChatCategory import com.kikidan.domain.model.chat.ChatEntry import com.kikidan.domain.model.chat.ChatQuota import com.kikidan.domain.model.chat.ChatSuggestion @@ -34,6 +35,11 @@ internal fun ChatEntryResponse.toDomain(): ChatEntry = ) internal fun ChatSuggestionResponse.toDomain(): ChatSuggestion = - ChatSuggestion(emoji = emoji, label = label, seedPrompt = seedPrompt, category = category) + ChatSuggestion( + emoji = emoji, + label = label, + seedPrompt = seedPrompt, + category = if (category == null) null else ChatCategory.valueOf(category), + ) internal fun ChatQuotaResponse.toDomain(): ChatQuota = ChatQuota(used = used, limit = limit) diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt index 9fda5f6..64942ea 100644 --- a/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt @@ -19,7 +19,7 @@ class ChatMapperTest { val response = ChatEntryResponse( greeting = "안녕하세요", - suggestions = listOf(ChatSuggestionResponse("🌟", "테스트", "seed", "CAREER")), + suggestions = listOf(ChatSuggestionResponse("🌟", "테스트", "seed", "LOVE")), quota = ChatQuotaResponse(used = 2, limit = 5), )