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..dc14e4e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImpl.kt @@ -0,0 +1,79 @@ +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/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt index fa0dcfa..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,9 @@ 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 import kotlinx.serialization.Serializable @Serializable @@ -14,7 +18,7 @@ data class ChatSuggestionResponse( val emoji: String, val label: String, val seedPrompt: String, - val category: String, + val category: String?, ) @Serializable @@ -22,3 +26,20 @@ 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 = 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/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 new file mode 100644 index 0000000..b45bfe4 --- /dev/null +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/datasource/RemoteChatDataSourceImplTest.kt @@ -0,0 +1,115 @@ +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 + +@Suppress("ktlint:standard:max-line-length") +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_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..64942ea --- /dev/null +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/dto/chat/ChatMapperTest.kt @@ -0,0 +1,148 @@ +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.MessageRole +import io.ktor.sse.ServerSentEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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_도메인으로_변환된다`() { + val response = + ChatEntryResponse( + greeting = "안녕하세요", + suggestions = listOf(ChatSuggestionResponse("🌟", "테스트", "seed", "LOVE")), + 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 `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} + """.trimIndent(), + ) + + 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_이벤트가__ChatStreamEvent_Error로_변환된다`() { + val event = ServerSentEvent(event = "error", data = """{"code":"QUOTA","message":"초과"}""") + + val result = event.toChatStreamEventOrNull() + + assertEquals(ChatStreamEvent.Error("QUOTA", "초과"), result) + } + + @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_ADD","label":"일정 추가","category":"SCHEDULE","date":"2026-08-10"}""", + ) + + val result = event.toChatStreamEventOrNull() as ChatStreamEvent.Action + + 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) + } + + @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..0542f53 --- /dev/null +++ b/core/data/src/test/java/com/kikidan/data/repository/ChatRepositoryImplTest.kt @@ -0,0 +1,107 @@ +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 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_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) + } +}